From 84efbe7450fa19413ae87f6d3550a75433edeafd Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 13:20:32 +0300 Subject: [PATCH] Rename product to Harbor and refine connection timer --- README.md | 8 +- index.html | 3 +- public/harbor-mark.svg | 9 + src/server/index.js | 11 +- src/web/App.jsx | 4 +- src/web/components/ClientOverviewPage.jsx | 73 +++++++- src/web/styles.css | 192 ++++++++++++++++++---- src/web/utils/clientControls.js | 41 ++++- test/web/client-controls.test.js | 9 + 9 files changed, 305 insertions(+), 45 deletions(-) create mode 100644 public/harbor-mark.svg diff --git a/README.md b/README.md index dd166cf..be294a8 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# VPN Proxy +# Harbor -Один компактный VPN-клиент в двух режимах: +Один компактный VPN-продукт в двух режимах: -- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или Gateway HTTP/SOCKS5 Proxy; -- `client` — локальный proxy-клиент для macOS. +- **Harbor Gateway** — отдельная Linux-машина принимает трафик устройств как системный Gateway или Gateway HTTP/SOCKS5 Proxy; +- **Harbor Connect** — локальный proxy-клиент для macOS. В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране. diff --git a/index.html b/index.html index 7de9e6e..94df9a6 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,8 @@ - VPN + + Harbor
diff --git a/public/harbor-mark.svg b/public/harbor-mark.svg new file mode 100644 index 0000000..04588e9 --- /dev/null +++ b/public/harbor-mark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/server/index.js b/src/server/index.js index 0893797..b5b03f1 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -2,6 +2,7 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; +import { isDeepStrictEqual } from 'node:util'; import { settings } from './config.js'; import { setGatewayInterception } from './gatewayRouting.js'; import { tcpPing } from './ping.js'; @@ -221,12 +222,18 @@ function refreshSavedSubscription() { const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; + const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && ( + !previousCache?.config || !isDeepStrictEqual( + buildGatewayConfig(previousCache.config, currentState.selectedTag), + buildGatewayConfig(parsed.config, selectedTag), + ) + ); writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed }); try { - if (singboxProcess && selectedTag) await applySelectedServer(selectedTag); + if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag); else if (selectedTag) { - writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag)); + if (!singboxProcess) writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag)); } else { removeSingboxConfig(); } diff --git a/src/web/App.jsx b/src/web/App.jsx index df0f05a..fa7b383 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -30,7 +30,7 @@ function App() { }, []); useEffect(() => { - if (state?.mode) document.title = state.mode === 'gateway' ? 'Gateway' : 'Proxy'; + if (state?.mode) document.title = state.mode === 'gateway' ? 'Harbor Gateway' : 'Harbor Connect'; }, [state?.mode]); async function run(action) { @@ -79,7 +79,7 @@ function App() { }); } - if (!state) return
VPN
; + if (!state) return
Harbor
; return (
diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 785fff3..6afd5c9 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -3,8 +3,8 @@ import { flushSync } from 'react-dom'; import { api } from '../api.js'; import { connectionAction, + connectionDurationParts, copyText, - formatConnectionDuration, localProxyUrls, subscriptionDomain, subscriptionDaysLeft, @@ -61,6 +61,30 @@ function InstructionBlock({ block, open, onToggle }) { ); } +function DurationPart({ name, children }) { + return ( + + {children} + + ); +} + +function HarborBrand({ isGateway }) { + const product = isGateway ? 'Gateway' : 'Connect'; + + return ( +
+ + Harbor {product} +
+ ); +} + export function ClientOverviewPage({ state, busy, @@ -84,6 +108,7 @@ export function ClientOverviewPage({ const showPower = hasSubscription && Boolean(selectedTag); const canStart = Boolean(selectedTag || state?.configExists); const [now, setNow] = useState(Date.now()); + const [durationMode, setDurationMode] = useState('digital'); const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); const [pings, setPings] = useState({}); const [accessTab, setAccessTab] = useState('gateway'); @@ -103,6 +128,7 @@ export function ClientOverviewPage({ const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress); const usage = subscriptionUsage(state?.userInfo); + const duration = connectionDurationParts(state?.singboxStartedAt, now); const [displayedUsed, setDisplayedUsed] = useState(usage.used); const hasUsage = Boolean( state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo), @@ -288,8 +314,13 @@ export function ClientOverviewPage({ document.startViewTransition(update); } + function toggleDurationMode() { + setDurationMode((mode) => mode === 'digital' ? 'words' : 'digital'); + } + return (
+ {hasSubscription && ) : (

{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'} diff --git a/src/web/styles.css b/src/web/styles.css index 554f15b..83fb2ac 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -60,6 +60,9 @@ p { --client-muted: oklch(0.53 0.014 145); --client-accent: oklch(0.62 0.14 151); --client-accent-soft: oklch(0.92 0.045 151); + --harbor-word: oklch(0.39 0.075 232); + --harbor-connect: oklch(0.57 0.11 185); + --harbor-gateway: oklch(0.61 0.12 72); grid-template-rows: 1fr; color-scheme: light; background: var(--client-bg); @@ -87,6 +90,43 @@ p { color: var(--client-text); } +.harbor-brand { + position: fixed; + top: 24px; + left: 28px; + display: flex; + align-items: center; + gap: 9px; + color: var(--harbor-word); + font-size: 13px; + letter-spacing: -0.035em; + user-select: none; +} + +.harbor-brand svg { + width: 24px; + height: 24px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.harbor-brand strong { + font-weight: 800; +} + +.harbor-brand em { + color: var(--harbor-connect); + font-style: normal; + font-weight: 600; +} + +.harbor-brand.is-gateway em { + color: var(--harbor-gateway); +} + .client-instructions-toggle { position: fixed; top: 50%; @@ -479,30 +519,50 @@ p { background: transparent; color: var(--client-muted); cursor: pointer; - transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1); + transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease; } -.client-power::before { +.client-power::before, +.client-power::after { content: ''; position: absolute; - width: 58px; - height: 58px; border-radius: 50%; - background: radial-gradient(circle, color-mix(in oklch, var(--client-accent) 42%, transparent), transparent 70%); - opacity: 0; - transform: scale(0.55); - transition: opacity 900ms cubic-bezier(0.16, 1, 0.3, 1), transform 900ms cubic-bezier(0.16, 1, 0.3, 1); pointer-events: none; } +.client-power::before { + width: 54px; + height: 54px; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in oklch, currentColor 38%, transparent), transparent 70%); + opacity: 0; + filter: blur(2px); + transform: translate3d(0, 4px, 0) scale(0.62); + transition: opacity 1050ms cubic-bezier(0.16, 1, 0.3, 1), transform 1250ms cubic-bezier(0.16, 1, 0.3, 1), filter 1050ms ease; +} + +.client-power::after { + width: 24px; + height: 24px; + background: radial-gradient(circle, color-mix(in oklch, currentColor 48%, transparent), transparent 72%); + opacity: 0; + filter: blur(5px); + transform: translate3d(-8px, 7px, 0) scale(0.7); + transition: opacity 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 950ms cubic-bezier(0.16, 1, 0.3, 1); +} + .client-power[aria-checked='true']::before { - opacity: 0.75; - transform: scale(1.75); + opacity: 0.76; + filter: blur(3px); + transform: translate3d(-3px, -2px, 0) scale(1.48); +} + +.client-power[aria-checked='true']::after { + animation: client-power-light-flicker 4.7s 650ms ease-in-out infinite; } .client-power:hover:not(:disabled) { - transform: translateY(-3px) scale(1.08); - color: var(--client-text); + color: var(--client-muted); } .client-power[aria-checked='true'] { @@ -510,8 +570,19 @@ p { } .client-power:active:not(:disabled) { - transform: translateY(0) scale(0.92); - transition-duration: 220ms; + opacity: 0.82; +} + +.client-power:active:not(:disabled)::before { + animation: none; + opacity: 0.86; + transform: translate3d(3px, -2px, 0) scale(1.56); +} + +.client-power:active:not(:disabled)::after { + animation: none; + opacity: 0.7; + transform: translate3d(-7px, 8px, 0) scale(1.18); } .client-power:disabled { @@ -528,11 +599,11 @@ p { stroke-width: 1.7; stroke-linecap: round; filter: drop-shadow(0 0 0 transparent); - transition: filter 700ms cubic-bezier(0.16, 1, 0.3, 1), transform 700ms cubic-bezier(0.16, 1, 0.3, 1); + transition: filter 700ms cubic-bezier(0.16, 1, 0.3, 1); } .client-power:hover:not(:disabled) svg { - filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-muted) 55%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-muted) 22%, transparent)); + filter: drop-shadow(0 0 4px color-mix(in oklch, var(--client-muted) 32%, transparent)); } .client-power[aria-checked='true']:hover:not(:disabled) { @@ -540,21 +611,18 @@ p { } .client-power[aria-checked='true']:hover:not(:disabled) svg { - filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 75%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 35%, transparent)); -} - -.client-power:active:not(:disabled) svg { - transform: scale(0.88); - filter: drop-shadow(0 0 10px var(--client-muted)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-muted) 45%, transparent)); - transition-duration: 220ms; -} - -.client-power[aria-checked='true']:active:not(:disabled) svg { - filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent)); + filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 48%, transparent)); } .client-power[aria-checked='true'] svg { - filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 65%, transparent)) drop-shadow(0 0 14px color-mix(in oklch, var(--client-accent) 28%, transparent)); + filter: drop-shadow(0 0 4px color-mix(in oklch, var(--client-accent) 42%, transparent)); +} + +@keyframes client-power-light-flicker { + 0%, 100% { opacity: 0.18; transform: translate3d(-8px, 7px, 0) scale(0.82); } + 19% { opacity: 0.5; transform: translate3d(7px, 5px, 0) scale(1.08); } + 46% { opacity: 0.26; transform: translate3d(8px, -7px, 0) scale(0.78); } + 71% { opacity: 0.58; transform: translate3d(-6px, -8px, 0) scale(1.12); } } .client-power-section h2 { @@ -577,11 +645,67 @@ p { } .client-duration { + grid-area: 1 / 1; display: block; color: var(--client-text); font-size: 14px; font-variant-numeric: tabular-nums; letter-spacing: 0.04em; + opacity: 0; + filter: blur(5px); + transition: opacity 260ms ease, filter 420ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-duration.is-active { + opacity: 1; + filter: blur(0); +} + +.client-duration-stack { + display: grid; + place-items: center; +} + +.client-duration-unit .client-duration-part.is-value { + display: inline-block; + min-width: 2ch; + text-align: right; +} + +.client-duration-unit .client-duration-part.is-label { + display: inline-block; + text-align: left; +} + +.client-duration-unit[data-unit='days'] .is-label { width: 4ch; } +.client-duration-unit[data-unit='hours'] .is-label { width: 5ch; } +.client-duration-unit[data-unit='minutes'] .is-label { width: 6ch; } +.client-duration-unit[data-unit='seconds'] .is-label { width: 7ch; } + +.client-duration-unit + .client-duration-unit::before { + content: ' '; +} + +.client-duration-toggle { + width: 290px; + min-height: 24px; + display: grid; + place-items: center; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.client-duration-toggle:hover .client-duration, +.client-duration-toggle:focus-visible .client-duration { + text-shadow: 0 0 10px color-mix(in oklch, var(--client-text) 22%, transparent); +} + +.client-duration-toggle:focus-visible { + outline: none; } .client-power-section p { @@ -1229,6 +1353,9 @@ p { --client-muted: oklch(0.68 0.012 145); --client-accent: oklch(0.76 0.13 151); --client-accent-soft: oklch(0.32 0.055 151); + --harbor-word: oklch(0.78 0.07 232); + --harbor-connect: oklch(0.75 0.1 185); + --harbor-gateway: oklch(0.79 0.11 72); color-scheme: dark; } } @@ -1246,6 +1373,13 @@ p { padding: 20px 6px; } + .harbor-brand { + position: absolute; + top: -4px; + left: 50%; + transform: translateX(-50%); + } + .client-instructions-toggle { left: 8px; } @@ -1277,6 +1411,7 @@ p { @media (prefers-reduced-motion: reduce) { .client-power, .client-power::before, + .client-power::after, .client-power svg, .client-usage-bar i, .client-subscription-refresh, @@ -1290,6 +1425,7 @@ p { .client-access-tab, .client-access-point, .client-copy-feedback, + .client-duration, .client-instructions, .client-instructions-toggle, .client-instructions-toggle svg, diff --git a/src/web/utils/clientControls.js b/src/web/utils/clientControls.js index cf6039d..8b83dff 100644 --- a/src/web/utils/clientControls.js +++ b/src/web/utils/clientControls.js @@ -6,17 +6,50 @@ export function connectionAction({ connected, selectedTag, configExists }) { } export function formatConnectionDuration(startedAt, now = Date.now()) { + const { totalHours, minutes, seconds } = connectionDurationParts(startedAt, now); + + return [totalHours, minutes.value, seconds.value] + .map((part) => String(part).padStart(2, '0')) + .join(':'); +} + +function durationLabel(value, forms) { + const mod10 = value % 10; + const mod100 = value % 100; + return mod10 === 1 && mod100 !== 11 + ? forms[0] + : mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? forms[1] : forms[2]; +} + +export function connectionDurationParts(startedAt, now = Date.now()) { const started = Date.parse(startedAt); const totalSeconds = Number.isFinite(started) ? Math.max(0, Math.floor((now - started) / 1000)) : 0; - const hours = Math.floor(totalSeconds / 3600); + const days = Math.floor(totalSeconds / 86_400); + const totalHours = Math.floor(totalSeconds / 3600); + const hours = Math.floor((totalSeconds % 86_400) / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; - return [hours, minutes, seconds] - .map((part) => String(part).padStart(2, '0')) - .join(':'); + return { + totalHours, + days: { value: days, label: durationLabel(days, ['день', 'дня', 'дней']) }, + hours: { value: hours, label: durationLabel(hours, ['час', 'часа', 'часов']) }, + minutes: { value: minutes, label: durationLabel(minutes, ['минута', 'минуты', 'минут']) }, + seconds: { value: seconds, label: durationLabel(seconds, ['секунда', 'секунды', 'секунд']) }, + }; +} + +export function formatConnectionDurationWords(startedAt, now = Date.now()) { + const { days, hours, minutes, seconds } = connectionDurationParts(startedAt, now); + + return [ + days.value && `${days.value} ${days.label}`, + hours.value && `${hours.value} ${hours.label}`, + minutes.value && `${minutes.value} ${minutes.label}`, + `${seconds.value} ${seconds.label}`, + ].filter(Boolean).join(' '); } export function subscriptionDomain(subscriptionHost) { diff --git a/test/web/client-controls.test.js b/test/web/client-controls.test.js index 5ce3b07..64f412b 100644 --- a/test/web/client-controls.test.js +++ b/test/web/client-controls.test.js @@ -5,6 +5,7 @@ import { connectionAction, copyText, formatConnectionDuration, + formatConnectionDurationWords, localProxyUrls, subscriptionDomain, subscriptionDaysLeft, @@ -30,6 +31,14 @@ test('connection duration is derived from the backend start time', () => { assert.equal(formatConnectionDuration(null, now), '00:00:00'); }); +test('connection duration can be written with Russian units', () => { + const startedAt = '2026-07-10T10:00:00.000Z'; + const now = Date.parse('2026-07-11T12:03:04.900Z'); + + assert.equal(formatConnectionDurationWords(startedAt, now), '1 день 2 часа 3 минуты 4 секунды'); + assert.equal(formatConnectionDurationWords(null, now), '0 секунд'); +}); + test('saved subscription is reduced to its public domain', () => { assert.equal(subscriptionDomain('sub.example.com/…'), 'sub.example.com'); assert.equal(subscriptionDomain(''), '');