Add home bypass mode for the Mac client
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 12s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-05-19 13:47:53 +03:00
parent d02dbe10de
commit c6352d781f
10 changed files with 199 additions and 8 deletions

View File

@@ -33,6 +33,9 @@ function App() {
proxyDefaultMode: 'vpn',
devices: [],
});
const [clientSettings, setClientSettings] = useState({
homeBypassEnabled: false,
});
const [selectedTag, setSelectedTag] = useState('');
const [pendingTag, setPendingTag] = useState('');
const [busy, setBusy] = useState(false);
@@ -78,6 +81,7 @@ function App() {
proxyDefaultMode: 'vpn',
devices: data.devices || [],
});
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
setSelectedTag((prev) => prev || data.selectedTag || '');
setPendingTag((prev) => prev || data.selectedTag || '');
}
@@ -261,6 +265,14 @@ function App() {
saveDevicesConfig(nextConfig);
}
async function saveClientSettings(nextSettings) {
return withBusy(null, async () => {
const data = await api.clientSettings.save(nextSettings);
setClientSettings(data.clientSettings || { homeBypassEnabled: false });
await loadState();
}, { quiet: true });
}
// === Rules CRUD ===
function emptyRule() {
return {
@@ -404,6 +416,8 @@ function App() {
servers={servers}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
clientSettings={clientSettings}
onSaveClientSettings={saveClientSettings}
onFetchSubscription={fetchSubscription}
onApply={applyServer}
onRestart={restartSingbox}

View File

@@ -44,6 +44,15 @@ export const api = {
}),
},
clientSettings: {
get: () => request("/api/client-settings"),
save: (clientSettings) =>
request("/api/client-settings", {
method: "PUT",
body: JSON.stringify({ clientSettings }),
}),
},
ruleSets: {
get: () => request("/api/rule-sets"),
save: (ruleSets) =>

View File

@@ -25,6 +25,7 @@ function CopyField({ label, value }) {
}
function ClientHero({ state, status, activeServer }) {
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
const cfg = {
running: {
title: 'Локальный proxy работает',
@@ -57,6 +58,15 @@ function ClientHero({ state, status, activeServer }) {
kind: 'neutral',
},
}[status] || {};
const view = homeBypass
? {
...cfg,
title: 'Домашний режим: VPN выключен',
hint: 'Локальный proxy работает напрямую',
badge: 'Напрямую',
kind: 'info',
}
: cfg;
const userInfo = state?.userInfo;
const traffic = userInfo
@@ -66,14 +76,14 @@ function ClientHero({ state, status, activeServer }) {
return (
<section className="client-hero">
<div className="client-hero-main">
<span className={`badge ${cfg.kind}`}>{cfg.badge}</span>
<h1>{cfg.title}</h1>
<p>{cfg.hint}</p>
<span className={`badge ${view.kind}`}>{view.badge}</span>
<h1>{view.title}</h1>
<p>{view.hint}</p>
</div>
<div className="client-hero-meta">
<div>
<small className="muted">Активный сервер</small>
<strong>{activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
<strong>{homeBypass ? 'Не используется дома' : activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
</div>
<div>
<small className="muted">Трафик</small>
@@ -101,6 +111,7 @@ function ClientSetup({
}) {
const selected = pendingTag || state?.selectedTag || '';
const canApply = selected && selected !== state?.selectedTag;
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
return (
<div className="card client-setup">
@@ -146,7 +157,9 @@ function ClientSetup({
</button>
</div>
<small className="field-hint">
В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.
{homeBypass
? 'Домашний режим включён: proxy-трафик сейчас идёт напрямую без VPN.'
: 'В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.'}
</small>
</div>
</div>
@@ -174,12 +187,43 @@ function ProxyCard({ state }) {
);
}
function HomeBypassCard({ settings, busy, onSave }) {
const enabled = Boolean(settings?.homeBypassEnabled);
return (
<div className="card">
<div className="card-header">
<h2>Домашний режим</h2>
<span className={`badge ${enabled ? 'info' : 'neutral'}`}>
{enabled ? 'Напрямую' : 'Через VPN'}
</span>
</div>
<p className="muted">
Включайте дома: приложения продолжают использовать <code>127.0.0.1:8080</code>, но VPN не используется.
</p>
<label className="switch-row">
<span>
<strong>Я дома</strong>
<small>{enabled ? 'Весь proxy-трафик идёт напрямую' : 'Весь proxy-трафик идёт через VPN'}</small>
</span>
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(e) => onSave({ ...settings, homeBypassEnabled: e.target.checked })}
/>
</label>
</div>
);
}
function ClientFlow({ state, activeServer }) {
const homeBypass = Boolean(state?.clientSettings?.homeBypassEnabled);
const steps = [
{ label: 'Mac', value: 'приложения' },
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
{ label: 'VPN-сервер', value: activeServer?.tag || state?.selectedTag || 'не выбран' },
{ label: 'Интернет', value: state?.singboxRunning ? 'через VPN' : 'ожидает' },
{ label: homeBypass ? 'Домашняя сеть' : 'VPN-сервер', value: homeBypass ? 'напрямую' : activeServer?.tag || state?.selectedTag || 'не выбран' },
{ label: 'Интернет', value: state?.singboxRunning ? homeBypass ? 'без VPN' : 'через VPN' : 'ожидает' },
];
return (
@@ -222,6 +266,8 @@ export function ClientOverviewPage({
servers,
pendingTag,
setPendingTag,
clientSettings,
onSaveClientSettings,
onFetchSubscription,
onApply,
onRestart,
@@ -243,6 +289,9 @@ export function ClientOverviewPage({
/>
<div className="grid-2">
<ProxyCard state={state} />
<HomeBypassCard settings={clientSettings} busy={busy} onSave={onSaveClientSettings} />
</div>
<div className="grid-2">
<ClientActions
state={state}
busy={busy}

View File

@@ -888,6 +888,28 @@ code, .mono {
flex-direction: column;
gap: var(--space-4);
}
.switch-row {
margin-top: var(--space-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-input);
background: var(--surface-2);
}
.switch-row span {
display: flex;
flex-direction: column;
gap: 2px;
}
.switch-row input {
width: 44px;
height: 24px;
flex: 0 0 44px;
accent-color: var(--accent);
}
.copy-field {
display: flex;
align-items: center;