feat: proxy change-IP link

This commit is contained in:
zhom committed 2026-10-12 00:32:08 +04:00
1 parent 69d3c90a7c
commit 3f07a41fac
25 files changed
+925 -51

No files matched your search

+19
View File
@@ -29,6 +29,7 @@ import {
LuLockOpen,
LuPlus,
LuPuzzle,
LuRefreshCcwDot,
LuRefreshCw,
LuSettings,
LuShield,
@@ -91,6 +92,7 @@ import { getProfileIcon, getProfileNativeOs } from "@/lib/browser-utils";
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
import { formatRelativeTime } from "@/lib/flag-utils";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { rotateProxyIps } from "@/lib/proxy-rotation";
import type { RemoteHandoffState } from "@/lib/remote-sessions";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
@@ -1751,6 +1753,9 @@ function NetworkSectionInline({
const [vpnId, setVpnId] = React.useState<string | null>(
profile.vpn_id ?? null,
);
const rotatableProxy = storedProxies.find(
(p) => p.id === proxyId && p.rotation_url,
);
const dnsServerId = React.useId();
const [savedDnsServer, setSavedDnsServer] = React.useState(
profile.dns_server ?? "",
@@ -1863,6 +1868,20 @@ function NetworkSectionInline({
))}
</SelectContent>
</Select>
{rotatableProxy && (
<Button
type="button"
variant="soft"
className="shrink-0"
data-slot="profile-proxy-rotate"
title={t("proxies.rotation.actionTooltip")}
disabled={isSaving}
onClick={() => void rotateProxyIps([rotatableProxy], t as never)}
>
<LuRefreshCcwDot className="size-3.5" />
{t("proxies.rotation.action")}
</Button>
)}
</div>
<div className="flex items-center gap-2">
+46 -1
View File
@@ -25,6 +25,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { translateBackendError } from "@/lib/backend-errors";
import { looksLikeRotationUrl } from "@/lib/proxy-rotation";
import {
isFirstHopEncrypted,
isXrayProxyType,
@@ -42,6 +43,7 @@ interface ProxyFormData {
username: string;
password: string;
vless_uri: string;
rotation_url: string;
}
interface ProxyFormDialogProps {
@@ -58,6 +60,7 @@ const DEFAULT_FORM: ProxyFormData = {
username: "",
password: "",
vless_uri: "",
rotation_url: "",
};
/**
@@ -175,6 +178,7 @@ export function ProxyFormDialog({
username: editingProxy.proxy_settings.username ?? "",
password: editingProxy.proxy_settings.password ?? "",
vless_uri: editingProxy.proxy_settings.vless_uri ?? "",
rotation_url: editingProxy.rotation_url ?? "",
});
}, [editingProxy, isOpen, resetForm]);
@@ -215,6 +219,12 @@ export function ProxyFormDialog({
return;
}
const rotationUrl = form.rotation_url.trim();
if (rotationUrl && !looksLikeRotationUrl(rotationUrl)) {
toast.error(t("backendErrors.proxyRotationUrlInvalid"));
return;
}
setIsSubmitting(true);
try {
const payload = {
@@ -234,9 +244,24 @@ export function ProxyFormDialog({
proxyId: editingProxy.id,
...payload,
});
if (rotationUrl !== (editingProxy.rotation_url ?? "")) {
await invoke("set_proxy_rotation_url", {
proxyId: editingProxy.id,
rotationUrl: rotationUrl || null,
});
}
toast.success(t("toasts.success.proxyUpdated"));
} else {
await invoke("create_stored_proxy", payload);
const created = await invoke<StoredProxy>(
"create_stored_proxy",
payload,
);
if (rotationUrl) {
await invoke("set_proxy_rotation_url", {
proxyId: created.id,
rotationUrl,
});
}
toast.success(t("toasts.success.proxyCreated"));
}
@@ -602,6 +627,26 @@ export function ProxyFormDialog({
)}
</>
)}
<div className="grid gap-2">
<Label htmlFor="proxy-rotation-url">
{t("proxies.rotation.label")}
</Label>
<Input
id="proxy-rotation-url"
value={form.rotation_url}
onChange={(e) => {
setForm({ ...form, rotation_url: e.target.value });
}}
placeholder={t("proxies.rotation.placeholder")}
spellCheck={false}
autoComplete="off"
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
{t("proxies.rotation.hint")}
</p>
</div>
</div>
<DialogFooter>
@@ -15,6 +15,7 @@ import { GoPlus } from "react-icons/go";
import {
LuDownload,
LuPencil,
LuRefreshCcwDot,
LuRefreshCw,
LuTrash2,
LuUpload,
@@ -63,6 +64,7 @@ import { useProfileReferences } from "@/hooks/use-profile-references";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { rotateProxyIps } from "@/lib/proxy-rotation";
import { isFirstHopEncrypted, proxyProtocolToken } from "@/lib/proxy-string";
import { canonicalProxyType } from "@/lib/proxy-type";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
@@ -655,6 +657,26 @@ export function ProxyManagementDialog({
return (
<div className="flex gap-1">
<ProxyCheckButton proxy={proxy} />
{proxy.rotation_url && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
data-slot="proxy-rotate"
aria-label={t("proxies.rotation.action")}
onClick={() => {
void rotateProxyIps([proxy], t);
}}
>
<LuRefreshCcwDot className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("proxies.rotation.actionTooltip")}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1386,6 +1408,19 @@ export function ProxyManagementDialog({
>
<LuRefreshCw />
</DataTableActionBarAction>
<DataTableActionBarAction
tooltip={t("proxies.rotation.action")}
onClick={() =>
void rotateProxyIps(
proxiesTable
.getSelectedRowModel()
.rows.map((row) => row.original),
t,
)
}
>
<LuRefreshCcwDot />
</DataTableActionBarAction>
<DataTableActionBarAction
tooltip={t("common.buttons.delete")}
onClick={() => {
+18 -1
View File
@@ -695,6 +695,18 @@
"skippedProxies_other": "{{count}} Proxys übersprungen, die noch von Profilen verwendet werden",
"skippedVpns_one": "{{count}} VPN übersprungen, das noch von einem Profil verwendet wird",
"skippedVpns_other": "{{count}} VPNs übersprungen, die noch von Profilen verwendet werden"
},
"rotation": {
"label": "Link zum IP-Wechsel",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "Optional. Ein Link Ihres Anbieters, der diesem Proxy eine neue IP gibt. Jedes Profil, das den Proxy nutzt, wechselt zur neuen IP.",
"action": "IP wechseln",
"actionTooltip": "Eine neue IP beim Anbieter anfordern. Jedes Profil, das diesen Proxy nutzt, wechselt zu ihr.",
"requested": "Neue IP für {{name}} angefordert",
"requestedMany_one": "Neue IP für {{count}} Proxy angefordert",
"requestedMany_other": "Neue IP für {{count}} Proxys angefordert",
"failedFor": "{{name}}: {{error}}",
"noneSelected": "Keiner dieser Proxys hat einen Link zum IP-Wechsel. Fügen Sie einen beim Bearbeiten eines Proxys hinzu."
}
},
"groups": {
@@ -2470,7 +2482,12 @@
"profileStatusNotFound": "Dieser Status existiert nicht mehr.",
"profileStatusColorInvalid": "Wählen Sie eine Farbe für den Status.",
"profileStatusNameTooLong": "Ein Statusname darf höchstens 40 Zeichen haben.",
"profileStatusLimitReached": "Sie haben 100 Status, das Maximum. Löschen Sie einen, um einen weiteren hinzuzufügen."
"profileStatusLimitReached": "Sie haben 100 Status, das Maximum. Löschen Sie einen, um einen weiteren hinzuzufügen.",
"proxyRotationUrlInvalid": "Der Link zum IP-Wechsel muss eine vollständige http://- oder https://-URL sein.",
"proxyRotationUrlMissing": "Dieser Proxy hat keinen Link zum IP-Wechsel. Fügen Sie einen beim Bearbeiten des Proxys hinzu.",
"proxyRotationFailed": "Der Anbieter hat den IP-Wechsel abgelehnt (HTTP {{status}}).",
"proxyRotationTimeout": "Der Anbieter hat innerhalb von 30 Sekunden nicht auf den IP-Wechsel geantwortet.",
"proxyRotationUnreachable": "Donut konnte den Link zum IP-Wechsel des Anbieters nicht erreichen."
},
"rail": {
"profiles": "Profile",
+18 -1
View File
@@ -695,6 +695,18 @@
"skippedProxies_other": "Skipped {{count}} proxies that are still in use by profiles",
"skippedVpns_one": "Skipped {{count}} VPN that is still in use by a profile",
"skippedVpns_other": "Skipped {{count}} VPNs that are still in use by profiles"
},
"rotation": {
"label": "Change IP link",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "Optional. A link from your provider that gives this proxy a new IP. Every profile that uses the proxy moves to the new IP.",
"action": "Change IP",
"actionTooltip": "Get a new IP from the provider. Every profile that uses this proxy moves to it.",
"requested": "New IP requested for {{name}}",
"requestedMany_one": "New IP requested for {{count}} proxy",
"requestedMany_other": "New IP requested for {{count}} proxies",
"failedFor": "{{name}}: {{error}}",
"noneSelected": "None of these proxies has a change IP link. Add one when you edit a proxy."
}
},
"groups": {
@@ -2470,7 +2482,12 @@
"profileStatusNotFound": "This status no longer exists.",
"profileStatusColorInvalid": "Pick a color for the status.",
"profileStatusNameTooLong": "A status name can have at most 40 characters.",
"profileStatusLimitReached": "You have 100 statuses, the maximum. Delete one to add another."
"profileStatusLimitReached": "You have 100 statuses, the maximum. Delete one to add another.",
"proxyRotationUrlInvalid": "The change IP link must be a full http:// or https:// URL.",
"proxyRotationUrlMissing": "This proxy has no change IP link. Add one when you edit the proxy.",
"proxyRotationFailed": "The provider refused the IP change (HTTP {{status}}).",
"proxyRotationTimeout": "The provider did not answer the IP change within 30 seconds.",
"proxyRotationUnreachable": "Donut could not reach the provider's change IP link."
},
"rail": {
"profiles": "Profiles",
+19 -1
View File
@@ -699,6 +699,19 @@
"skippedVpns_one": "Se omitió {{count}} VPN que todavía usa un perfil",
"skippedVpns_many": "Se omitieron {{count}} VPN que todavía usan algunos perfiles",
"skippedVpns_other": "Se omitieron {{count}} VPN que todavía usan algunos perfiles"
},
"rotation": {
"label": "Enlace para cambiar IP",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "Opcional. Un enlace de tu proveedor que da a este proxy una IP nueva. Todos los perfiles que usan el proxy pasan a la IP nueva.",
"action": "Cambiar IP",
"actionTooltip": "Pedir una IP nueva al proveedor. Todos los perfiles que usan este proxy pasan a ella.",
"requested": "IP nueva solicitada para {{name}}",
"requestedMany_one": "IP nueva solicitada para {{count}} proxy",
"requestedMany_many": "IP nueva solicitada para {{count}} proxies",
"requestedMany_other": "IP nueva solicitada para {{count}} proxies",
"failedFor": "{{name}}: {{error}}",
"noneSelected": "Ninguno de estos proxies tiene un enlace para cambiar IP. Añade uno al editar un proxy."
}
},
"groups": {
@@ -2480,7 +2493,12 @@
"profileStatusNotFound": "Este estado ya no existe.",
"profileStatusColorInvalid": "Elige un color para el estado.",
"profileStatusNameTooLong": "El nombre de un estado puede tener como máximo 40 caracteres.",
"profileStatusLimitReached": "Tienes 100 estados, el máximo. Elimina uno para añadir otro."
"profileStatusLimitReached": "Tienes 100 estados, el máximo. Elimina uno para añadir otro.",
"proxyRotationUrlInvalid": "El enlace para cambiar IP debe ser una URL completa http:// o https://.",
"proxyRotationUrlMissing": "Este proxy no tiene enlace para cambiar IP. Añade uno al editar el proxy.",
"proxyRotationFailed": "El proveedor rechazó el cambio de IP (HTTP {{status}}).",
"proxyRotationTimeout": "El proveedor no respondió al cambio de IP en 30 segundos.",
"proxyRotationUnreachable": "Donut no pudo acceder al enlace para cambiar IP del proveedor."
},
"rail": {
"profiles": "Perfiles",
+19 -1
View File
@@ -699,6 +699,19 @@
"skippedVpns_one": "{{count}} VPN ignoré car encore utilisé par un profil",
"skippedVpns_many": "{{count}} VPN ignorés car encore utilisés par des profils",
"skippedVpns_other": "{{count}} VPN ignorés car encore utilisés par des profils"
},
"rotation": {
"label": "Lien de changement d'IP",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "Facultatif. Un lien de votre fournisseur qui donne une nouvelle IP à ce proxy. Tous les profils qui utilisent le proxy passent à la nouvelle IP.",
"action": "Changer d'IP",
"actionTooltip": "Demander une nouvelle IP au fournisseur. Tous les profils qui utilisent ce proxy y passent.",
"requested": "Nouvelle IP demandée pour {{name}}",
"requestedMany_one": "Nouvelle IP demandée pour {{count}} proxy",
"requestedMany_many": "Nouvelle IP demandée pour {{count}} proxys",
"requestedMany_other": "Nouvelle IP demandée pour {{count}} proxys",
"failedFor": "{{name}} : {{error}}",
"noneSelected": "Aucun de ces proxys n'a de lien de changement d'IP. Ajoutez-en un en modifiant un proxy."
}
},
"groups": {
@@ -2480,7 +2493,12 @@
"profileStatusNotFound": "Ce statut n'existe plus.",
"profileStatusColorInvalid": "Choisissez une couleur pour le statut.",
"profileStatusNameTooLong": "Le nom d'un statut ne peut pas dépasser 40 caractères.",
"profileStatusLimitReached": "Vous avez 100 statuts, le maximum. Supprimez-en un pour en ajouter un autre."
"profileStatusLimitReached": "Vous avez 100 statuts, le maximum. Supprimez-en un pour en ajouter un autre.",
"proxyRotationUrlInvalid": "Le lien de changement d'IP doit être une URL http:// ou https:// complète.",
"proxyRotationUrlMissing": "Ce proxy n'a pas de lien de changement d'IP. Ajoutez-en un en modifiant le proxy.",
"proxyRotationFailed": "Le fournisseur a refusé le changement d'IP (HTTP {{status}}).",
"proxyRotationTimeout": "Le fournisseur n'a pas répondu au changement d'IP en 30 secondes.",
"proxyRotationUnreachable": "Donut n'a pas pu joindre le lien de changement d'IP du fournisseur."
},
"rail": {
"profiles": "Profils",
+18 -1
View File
@@ -695,6 +695,18 @@
"skippedProxies_other": "プロファイルで使用中のため、{{count}} 件のプロキシをスキップしました",
"skippedVpns_one": "プロファイルで使用中のため、{{count}} 件の VPN をスキップしました",
"skippedVpns_other": "プロファイルで使用中のため、{{count}} 件の VPN をスキップしました"
},
"rotation": {
"label": "IP 変更リンク",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "任意。このプロキシに新しい IP を割り当てるプロバイダーのリンクです。このプロキシを使うすべてのプロファイルが新しい IP に移ります。",
"action": "IP を変更",
"actionTooltip": "プロバイダーから新しい IP を取得します。このプロキシを使うすべてのプロファイルがその IP に移ります。",
"requested": "{{name}} の新しい IP をリクエストしました",
"requestedMany_one": "{{count}} 件のプロキシの新しい IP をリクエストしました",
"requestedMany_other": "{{count}} 件のプロキシの新しい IP をリクエストしました",
"failedFor": "{{name}}: {{error}}",
"noneSelected": "これらのプロキシには IP 変更リンクがありません。プロキシの編集時に追加してください。"
}
},
"groups": {
@@ -2470,7 +2482,12 @@
"profileStatusNotFound": "このステータスはもう存在しません。",
"profileStatusColorInvalid": "ステータスの色を選択してください。",
"profileStatusNameTooLong": "ステータス名は 40 文字以内にしてください。",
"profileStatusLimitReached": "ステータスが上限の 100 個に達しています。追加するには 1 つ削除してください。"
"profileStatusLimitReached": "ステータスが上限の 100 個に達しています。追加するには 1 つ削除してください。",
"proxyRotationUrlInvalid": "IP 変更リンクは完全な http:// または https:// URL にしてください。",
"proxyRotationUrlMissing": "このプロキシには IP 変更リンクがありません。プロキシの編集時に追加してください。",
"proxyRotationFailed": "プロバイダーが IP 変更を拒否しました (HTTP {{status}})。",
"proxyRotationTimeout": "プロバイダーが 30 秒以内に IP 変更に応答しませんでした。",
"proxyRotationUnreachable": "プロバイダーの IP 変更リンクに接続できませんでした。"
},
"rail": {
"profiles": "プロファイル",
+18 -1
View File
@@ -695,6 +695,18 @@
"skippedProxies_other": "프로필에서 사용 중이어서 프록시 {{count}}개를 건너뛰었습니다",
"skippedVpns_one": "프로필에서 사용 중이어서 VPN {{count}}개를 건너뛰었습니다",
"skippedVpns_other": "프로필에서 사용 중이어서 VPN {{count}}개를 건너뛰었습니다"
},
"rotation": {
"label": "IP 변경 링크",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "선택 사항. 이 프록시에 새 IP를 주는 공급자의 링크입니다. 이 프록시를 쓰는 모든 프로필이 새 IP로 이동합니다.",
"action": "IP 변경",
"actionTooltip": "공급자에게서 새 IP를 받습니다. 이 프록시를 쓰는 모든 프로필이 그 IP로 이동합니다.",
"requested": "{{name}}의 새 IP를 요청했습니다",
"requestedMany_one": "프록시 {{count}}개의 새 IP를 요청했습니다",
"requestedMany_other": "프록시 {{count}}개의 새 IP를 요청했습니다",
"failedFor": "{{name}}: {{error}}",
"noneSelected": "이 프록시들에는 IP 변경 링크가 없습니다. 프록시를 편집할 때 추가하세요."
}
},
"groups": {
@@ -2470,7 +2482,12 @@
"profileStatusNotFound": "이 상태는 더 이상 존재하지 않습니다.",
"profileStatusColorInvalid": "상태 색상을 선택하세요.",
"profileStatusNameTooLong": "상태 이름은 최대 40자까지 가능합니다.",
"profileStatusLimitReached": "상태가 최대치인 100개입니다. 추가하려면 하나를 삭제하세요."
"profileStatusLimitReached": "상태가 최대치인 100개입니다. 추가하려면 하나를 삭제하세요.",
"proxyRotationUrlInvalid": "IP 변경 링크는 전체 http:// 또는 https:// URL이어야 합니다.",
"proxyRotationUrlMissing": "이 프록시에는 IP 변경 링크가 없습니다. 프록시를 편집할 때 추가하세요.",
"proxyRotationFailed": "공급자가 IP 변경을 거부했습니다 (HTTP {{status}}).",
"proxyRotationTimeout": "공급자가 30초 안에 IP 변경에 응답하지 않았습니다.",
"proxyRotationUnreachable": "공급자의 IP 변경 링크에 연결할 수 없습니다."
},
"rail": {
"profiles": "프로필",
+19 -1
View File
@@ -699,6 +699,19 @@
"skippedVpns_one": "{{count}} VPN ignorada por ainda estar em uso por um perfil",
"skippedVpns_many": "{{count}} VPNs ignoradas por ainda estarem em uso por perfis",
"skippedVpns_other": "{{count}} VPNs ignoradas por ainda estarem em uso por perfis"
},
"rotation": {
"label": "Link de troca de IP",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "Opcional. Um link do seu provedor que dá um IP novo a este proxy. Todos os perfis que usam o proxy passam para o IP novo.",
"action": "Trocar IP",
"actionTooltip": "Pedir um IP novo ao provedor. Todos os perfis que usam este proxy passam para ele.",
"requested": "IP novo solicitado para {{name}}",
"requestedMany_one": "IP novo solicitado para {{count}} proxy",
"requestedMany_many": "IP novo solicitado para {{count}} proxies",
"requestedMany_other": "IP novo solicitado para {{count}} proxies",
"failedFor": "{{name}}: {{error}}",
"noneSelected": "Nenhum destes proxies tem link de troca de IP. Adicione um ao editar um proxy."
}
},
"groups": {
@@ -2480,7 +2493,12 @@
"profileStatusNotFound": "Este status não existe mais.",
"profileStatusColorInvalid": "Escolha uma cor para o status.",
"profileStatusNameTooLong": "O nome de um status pode ter no máximo 40 caracteres.",
"profileStatusLimitReached": "Você tem 100 status, o máximo. Exclua um para adicionar outro."
"profileStatusLimitReached": "Você tem 100 status, o máximo. Exclua um para adicionar outro.",
"proxyRotationUrlInvalid": "O link de troca de IP precisa ser uma URL completa http:// ou https://.",
"proxyRotationUrlMissing": "Este proxy não tem link de troca de IP. Adicione um ao editar o proxy.",
"proxyRotationFailed": "O provedor recusou a troca de IP (HTTP {{status}}).",
"proxyRotationTimeout": "O provedor não respondeu à troca de IP em 30 segundos.",
"proxyRotationUnreachable": "O Donut não conseguiu acessar o link de troca de IP do provedor."
},
"rail": {
"profiles": "Perfis",
+20 -1
View File
@@ -703,6 +703,20 @@
"skippedVpns_few": "Пропущено {{count}} VPN, которые ещё используются профилями",
"skippedVpns_many": "Пропущено {{count}} VPN, которые ещё используются профилями",
"skippedVpns_other": "Пропущено {{count}} VPN, которые ещё используются профилями"
},
"rotation": {
"label": "Ссылка для смены IP",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "Необязательно. Ссылка провайдера, которая даёт этому прокси новый IP. Все профили с этим прокси переходят на новый IP.",
"action": "Сменить IP",
"actionTooltip": "Получить новый IP у провайдера. Все профили с этим прокси перейдут на него.",
"requested": "Запрошен новый IP для {{name}}",
"requestedMany_one": "Запрошен новый IP для {{count}} прокси",
"requestedMany_few": "Запрошены новые IP для {{count}} прокси",
"requestedMany_many": "Запрошены новые IP для {{count}} прокси",
"requestedMany_other": "Запрошены новые IP для {{count}} прокси",
"failedFor": "{{name}}: {{error}}",
"noneSelected": "Ни у одного из этих прокси нет ссылки для смены IP. Добавьте её при редактировании прокси."
}
},
"groups": {
@@ -2490,7 +2504,12 @@
"profileStatusNotFound": "Этого статуса больше нет.",
"profileStatusColorInvalid": "Выберите цвет статуса.",
"profileStatusNameTooLong": "Название статуса может содержать не более 40 символов.",
"profileStatusLimitReached": "У вас 100 статусов, это максимум. Удалите один, чтобы добавить другой."
"profileStatusLimitReached": "У вас 100 статусов, это максимум. Удалите один, чтобы добавить другой.",
"proxyRotationUrlInvalid": "Ссылка для смены IP должна быть полным URL с http:// или https://.",
"proxyRotationUrlMissing": "У этого прокси нет ссылки для смены IP. Добавьте её при редактировании прокси.",
"proxyRotationFailed": "Провайдер отклонил смену IP (HTTP {{status}}).",
"proxyRotationTimeout": "Провайдер не ответил на смену IP за 30 секунд.",
"proxyRotationUnreachable": "Donut не удалось открыть ссылку провайдера для смены IP."
},
"rail": {
"profiles": "Профили",
+18 -1
View File
@@ -695,6 +695,18 @@
"skippedProxies_other": "Bir profil tarafından hâlâ kullanıldığı için {{count}} proxy atlandı",
"skippedVpns_one": "Bir profil tarafından hâlâ kullanıldığı için {{count}} VPN atlandı",
"skippedVpns_other": "Bir profil tarafından hâlâ kullanıldığı için {{count}} VPN atlandı"
},
"rotation": {
"label": "IP değiştirme bağlantısı",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "İsteğe bağlı. Sağlayıcınızın bu proxy'ye yeni bir IP veren bağlantısı. Proxy'yi kullanan her profil yeni IP'ye geçer.",
"action": "IP değiştir",
"actionTooltip": "Sağlayıcıdan yeni bir IP alın. Bu proxy'yi kullanan her profil ona geçer.",
"requested": "{{name}} için yeni IP istendi",
"requestedMany_one": "{{count}} proxy için yeni IP istendi",
"requestedMany_other": "{{count}} proxy için yeni IP istendi",
"failedFor": "{{name}}: {{error}}",
"noneSelected": "Bu proxy'lerin hiçbirinde IP değiştirme bağlantısı yok. Bir proxy'yi düzenlerken ekleyin."
}
},
"groups": {
@@ -2470,7 +2482,12 @@
"profileStatusNotFound": "Bu durum artık yok.",
"profileStatusColorInvalid": "Durum için bir renk seçin.",
"profileStatusNameTooLong": "Durum adı en fazla 40 karakter olabilir.",
"profileStatusLimitReached": "En fazla sayı olan 100 durumunuz var. Yenisini eklemek için birini silin."
"profileStatusLimitReached": "En fazla sayı olan 100 durumunuz var. Yenisini eklemek için birini silin.",
"proxyRotationUrlInvalid": "IP değiştirme bağlantısı tam bir http:// veya https:// URL'si olmalıdır.",
"proxyRotationUrlMissing": "Bu proxy'nin IP değiştirme bağlantısı yok. Proxy'yi düzenlerken ekleyin.",
"proxyRotationFailed": "Sağlayıcı IP değişikliğini reddetti (HTTP {{status}}).",
"proxyRotationTimeout": "Sağlayıcı IP değişikliğine 30 saniye içinde yanıt vermedi.",
"proxyRotationUnreachable": "Donut, sağlayıcının IP değiştirme bağlantısına ulaşamadı."
},
"rail": {
"profiles": "Profiller",
+18 -1
View File
@@ -695,6 +695,18 @@
"skippedProxies_other": "Đã bỏ qua {{count}} proxy vẫn đang được hồ sơ sử dụng",
"skippedVpns_one": "Đã bỏ qua {{count}} VPN vẫn đang được hồ sơ sử dụng",
"skippedVpns_other": "Đã bỏ qua {{count}} VPN vẫn đang được hồ sơ sử dụng"
},
"rotation": {
"label": "Liên kết đổi IP",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "Không bắt buộc. Liên kết từ nhà cung cấp để cấp IP mới cho proxy này. Mọi profile dùng proxy sẽ chuyển sang IP mới.",
"action": "Đổi IP",
"actionTooltip": "Lấy IP mới từ nhà cung cấp. Mọi profile dùng proxy này sẽ chuyển sang IP đó.",
"requested": "Đã yêu cầu IP mới cho {{name}}",
"requestedMany_one": "Đã yêu cầu IP mới cho {{count}} proxy",
"requestedMany_other": "Đã yêu cầu IP mới cho {{count}} proxy",
"failedFor": "{{name}}: {{error}}",
"noneSelected": "Không proxy nào trong số này có liên kết đổi IP. Hãy thêm khi chỉnh sửa proxy."
}
},
"groups": {
@@ -2470,7 +2482,12 @@
"profileStatusNotFound": "Trạng thái này không còn tồn tại.",
"profileStatusColorInvalid": "Hãy chọn màu cho trạng thái.",
"profileStatusNameTooLong": "Tên trạng thái tối đa 40 ký tự.",
"profileStatusLimitReached": "Bạn đã có 100 trạng thái, mức tối đa. Hãy xóa một trạng thái để thêm mới."
"profileStatusLimitReached": "Bạn đã có 100 trạng thái, mức tối đa. Hãy xóa một trạng thái để thêm mới.",
"proxyRotationUrlInvalid": "Liên kết đổi IP phải là URL http:// hoặc https:// đầy đủ.",
"proxyRotationUrlMissing": "Proxy này chưa có liên kết đổi IP. Hãy thêm khi chỉnh sửa proxy.",
"proxyRotationFailed": "Nhà cung cấp từ chối đổi IP (HTTP {{status}}).",
"proxyRotationTimeout": "Nhà cung cấp không phản hồi yêu cầu đổi IP trong 30 giây.",
"proxyRotationUnreachable": "Donut không thể truy cập liên kết đổi IP của nhà cung cấp."
},
"rail": {
"profiles": "Profile",
+18 -1
View File
@@ -695,6 +695,18 @@
"skippedProxies_other": "已跳过 {{count}} 个仍被配置文件使用的代理",
"skippedVpns_one": "已跳过 {{count}} 个仍被配置文件使用的 VPN",
"skippedVpns_other": "已跳过 {{count}} 个仍被配置文件使用的 VPN"
},
"rotation": {
"label": "更换 IP 链接",
"placeholder": "https://provider.example/change-ip?key=…",
"hint": "可选。服务商提供的链接,可为此代理更换新 IP。使用此代理的所有配置文件都会换到新 IP。",
"action": "更换 IP",
"actionTooltip": "向服务商获取新 IP。使用此代理的所有配置文件都会换到该 IP。",
"requested": "已为 {{name}} 请求新 IP",
"requestedMany_one": "已为 {{count}} 个代理请求新 IP",
"requestedMany_other": "已为 {{count}} 个代理请求新 IP",
"failedFor": "{{name}}:{{error}}",
"noneSelected": "这些代理都没有更换 IP 链接。请在编辑代理时添加。"
}
},
"groups": {
@@ -2470,7 +2482,12 @@
"profileStatusNotFound": "此状态已不存在。",
"profileStatusColorInvalid": "请为状态选择颜色。",
"profileStatusNameTooLong": "状态名称最多 40 个字符。",
"profileStatusLimitReached": "状态数量已达上限 100 个。请删除一个后再添加。"
"profileStatusLimitReached": "状态数量已达上限 100 个。请删除一个后再添加。",
"proxyRotationUrlInvalid": "更换 IP 链接必须是完整的 http:// 或 https:// URL。",
"proxyRotationUrlMissing": "此代理没有更换 IP 链接。请在编辑代理时添加。",
"proxyRotationFailed": "服务商拒绝了更换 IP(HTTP {{status}})。",
"proxyRotationTimeout": "服务商在 30 秒内未响应更换 IP。",
"proxyRotationUnreachable": "Donut 无法访问服务商的更换 IP 链接。"
},
"rail": {
"profiles": "配置文件",
+17
View File
@@ -30,6 +30,11 @@ export type BackendErrorCode =
| "PROFILE_STATUS_COLOR_INVALID"
| "PROFILE_STATUS_NAME_TOO_LONG"
| "PROFILE_STATUS_LIMIT_REACHED"
| "PROXY_ROTATION_URL_INVALID"
| "PROXY_ROTATION_URL_MISSING"
| "PROXY_ROTATION_TIMEOUT"
| "PROXY_ROTATION_UNREACHABLE"
| "PROXY_ROTATION_FAILED"
| "COOKIE_DB_LOCKED"
| "COOKIE_DB_UNAVAILABLE"
| "COOKIE_IMPORT_BROWSER_RUNNING"
@@ -376,6 +381,18 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.profileStatusNameTooLong");
case "PROFILE_STATUS_LIMIT_REACHED":
return t("backendErrors.profileStatusLimitReached");
case "PROXY_ROTATION_URL_INVALID":
return t("backendErrors.proxyRotationUrlInvalid");
case "PROXY_ROTATION_URL_MISSING":
return t("backendErrors.proxyRotationUrlMissing");
case "PROXY_ROTATION_TIMEOUT":
return t("backendErrors.proxyRotationTimeout");
case "PROXY_ROTATION_UNREACHABLE":
return t("backendErrors.proxyRotationUnreachable");
case "PROXY_ROTATION_FAILED":
return t("backendErrors.proxyRotationFailed", {
status: parsed.params?.status ?? "",
});
case "COOKIE_DB_LOCKED":
return t("backendErrors.cookieDbLocked");
case "COOKIE_DB_UNAVAILABLE":
+67
View File
@@ -0,0 +1,67 @@
import { invoke } from "@tauri-apps/api/core";
import type { TFunction } from "i18next";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import type { StoredProxy } from "@/types";
/** `RotationResult` in `src-tauri/src/proxy_rotation.rs`. */
interface RotationResult {
proxy_id: string;
status: number | null;
elapsed_ms: number;
error: string | null;
}
/** A link worth sending to the backend, which checks it again. */
export function looksLikeRotationUrl(value: string): boolean {
return /^https?:\/\/\S+$/i.test(value.trim());
}
/**
* Ask the providers of these proxies for new IPs and say what happened. Proxies
* without a change-IP link are left out.
*/
export async function rotateProxyIps(
proxies: StoredProxy[],
t: TFunction,
): Promise<void> {
const rotatable = proxies.filter((proxy) => proxy.rotation_url);
if (rotatable.length === 0) {
showErrorToast(t("proxies.rotation.noneSelected"));
return;
}
if (rotatable.length === 1) {
const [proxy] = rotatable;
try {
await invoke<RotationResult>("rotate_proxy_ip", { proxyId: proxy.id });
showSuccessToast(t("proxies.rotation.requested", { name: proxy.name }));
} catch (error) {
showErrorToast(
t("proxies.rotation.failedFor", {
name: proxy.name,
error: translateBackendError(t, error),
}),
);
}
return;
}
const results = await invoke<RotationResult[]>("rotate_proxy_ips", {
proxyIds: rotatable.map((proxy) => proxy.id),
});
const succeeded = results.filter((result) => !result.error).length;
if (succeeded > 0) {
showSuccessToast(t("proxies.rotation.requestedMany", { count: succeeded }));
}
for (const result of results) {
if (!result.error) continue;
const name =
rotatable.find((proxy) => proxy.id === result.proxy_id)?.name ??
result.proxy_id;
showErrorToast(
t("proxies.rotation.failedFor", {
name,
error: translateBackendError(t, result.error),
}),
);
}
}
+2
View File
@@ -329,6 +329,8 @@ export interface StoredProxy {
geo_region?: string;
geo_city?: string;
geo_isp?: string;
/** The provider's change-IP link; see `rotateProxyIps`. */
rotation_url?: string | null;
}
export interface LocationItem {