Browse Source

macos: add optional menu bar controller

Co-authored-by: Cursor <[email protected]>
pull/2130/head
pitk150-alt 4 weeks ago
parent
commit
e8dc7e5bcb
  1. 24
      extras/macos-menu/Info.plist
  2. 124
      extras/macos-menu/README.md
  3. BIN
      extras/macos-menu/Resources/ZapretIcon.icns
  4. 731
      extras/macos-menu/Sources/ZapretMenu.swift
  5. 29
      extras/macos-menu/build.sh
  6. 75
      extras/macos-menu/install.sh
  7. 25
      extras/macos-menu/uninstall.sh
  8. 35
      extras/macos-menu/zapret-menu-helper

24
extras/macos-menu/Info.plist

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Zapret Menu</string>
<key>CFBundleIdentifier</key>
<string>org.zapret.menu</string>
<key>CFBundleName</key>
<string>Zapret Menu</string>
<key>CFBundleDisplayName</key>
<string>Zapret Menu</string>
<key>CFBundleIconFile</key>
<string>ZapretIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>

124
extras/macos-menu/README.md

@ -0,0 +1,124 @@
# Zapret Menu for macOS
> **Attention**
>
> Это репозиторий fork от версии `zapret`. В данной адаптации для macOS добавлена совместимость с macOS и визуальный интерфейс для удобной работы без ручного запуска скриптов.
>
> **P.S.** Человек не написал ни одной строки добавленного кода вручную; всё было сделано Cursor + GPT-5.5.
>
> This repository is a fork/adaptation of `zapret`. This macOS-oriented solution adds compatibility notes for macOS usage and a visual menu bar interface so users can work with zapret without manually running shell scripts.
>
> **P.S.** No human wrote a single line of this added code manually; it was generated and assembled with Cursor + GPT-5.5.
Optional macOS menu bar controller for a local zapret installation.
The app lives in the macOS menu bar and provides:
- start, stop, and restart controls;
- hostlist update;
- connection check;
- human-readable status;
- Russian/English interface switch;
- launch at user login while keeping zapret itself off after reboot.
## Requirements
- macOS;
- Xcode Command Line Tools (`swiftc`);
- zapret installed at `/opt/zapret` (or another path via `ZAPRET_BASE`);
- administrator account for installing the helper and sudoers rule.
Install Command Line Tools if needed:
```sh
xcode-select --install
```
## Install
From the repository root:
```sh
extras/macos-menu/install.sh
```
Custom zapret location:
```sh
ZAPRET_BASE=/opt/zapret extras/macos-menu/install.sh
```
Custom app install directory:
```sh
INSTALL_DIR="$HOME/Applications/Zapret Control" extras/macos-menu/install.sh
```
The installer:
1. Builds `Zapret Menu.app`.
2. Copies it to `$HOME/Applications/Zapret Control`.
3. Installs `/opt/zapret/zapret-menu-helper`.
4. Adds a limited sudoers rule in `/etc/sudoers.d/zapret-menu`.
5. Adds a user LaunchAgent so the menu app starts at login.
## Security note
The menu app needs elevated privileges because zapret controls PF rules and root-owned daemons.
The installer does **not** grant broad passwordless sudo. It grants passwordless access only to:
```text
/opt/zapret/zapret-menu-helper start
/opt/zapret/zapret-menu-helper stop
/opt/zapret/zapret-menu-helper restart
/opt/zapret/zapret-menu-helper update
```
The sudoers file is validated with `visudo -cf` before installation.
## Use
Menu bar icons:
- `📳` zapret is running;
- `📴` zapret is stopped;
- `🔀` zapret is restarting.
Menu actions:
- `📳 Start` starts zapret.
- `📴 Stop` stops zapret and clears rules.
- `🔀 Restart` refreshes zapret only when it is already running and internet check passes.
- `🔂 Update Hostlist` downloads the domain list.
- `📶 Check Connection` checks internet reachability with ping to `1.1.1.1` and HTTPS request to `apple.com`.
- `▶ Show Status` shows runtime, last stop, list update date, and list sizes.
- `ℹ️ About` shows app dates and a short usage guide.
- `✖ Quit` stops zapret first, verifies it stopped, then closes the menu app.
## Uninstall
```sh
extras/macos-menu/uninstall.sh
```
The uninstaller removes:
- user LaunchAgent;
- menu app bundle;
- privileged helper;
- sudoers rule.
It does not remove zapret itself.
## Build only
```sh
extras/macos-menu/build.sh
```
The built app is written to:
```text
extras/macos-menu/build/Zapret Menu.app
```

BIN
extras/macos-menu/Resources/ZapretIcon.icns

Binary file not shown.

731
extras/macos-menu/Sources/ZapretMenu.swift

@ -0,0 +1,731 @@
import Cocoa
import UserNotifications
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemValidation {
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let menu = NSMenu()
private let languageKey = "ZapretMenuLanguage"
private let startTimeKey = "ZapretLastStartTime"
private let stopTimeKey = "ZapretLastStopTime"
private var refreshTimer: Timer?
private var restartingUntil: Date?
private var lockFileDescriptor: Int32 = -1
private enum Language: String {
case auto
case ru
case en
}
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
guard acquireSingleInstanceLock() else {
NSApp.terminate(nil)
return
}
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
if let button = statusItem.button {
button.toolTip = "Zapret"
}
rebuildMenu()
refreshTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.updateStatusIcon()
}
}
private func rebuildMenu() {
menu.removeAllItems()
menu.delegate = self
menu.autoenablesItems = true
updateStatusIcon()
let running = isZapretRunning()
let connectionAvailable = isInternetReachable()
let header = NSMenuItem(title: currentStatusTitle(), action: nil, keyEquivalent: "")
header.isEnabled = false
menu.addItem(header)
menu.addItem(.separator())
let startItem = item(text("start"), #selector(startZapret))
startItem.isEnabled = !running
menu.addItem(startItem)
let stopItem = item(text("stop"), #selector(stopZapret))
stopItem.isEnabled = running
menu.addItem(stopItem)
let restartItem = item(text("restart"), #selector(restartZapret))
restartItem.isEnabled = running && connectionAvailable
menu.addItem(restartItem)
menu.addItem(.separator())
menu.addItem(item(text("updateHostlist"), #selector(updateHostlist)))
menu.addItem(item(text("checkConnection"), #selector(checkConnection)))
menu.addItem(item(text("showStatus"), #selector(showStatus)))
menu.addItem(item(text("about"), #selector(showAbout)))
menu.addItem(item(languageMenuTitle(), #selector(toggleLanguage)))
menu.addItem(.separator())
menu.addItem(item(text("quit"), #selector(quit)))
statusItem.menu = menu
}
func menuWillOpen(_ menu: NSMenu) {
rebuildMenu()
}
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
switch menuItem.action {
case #selector(startZapret):
return !isZapretRunning()
case #selector(stopZapret):
return isZapretRunning()
case #selector(restartZapret):
return isZapretRunning() && isInternetReachable()
default:
return true
}
}
private func updateStatusIcon() {
if let button = statusItem.button {
if let until = restartingUntil, Date() < until {
button.title = "🔀"
} else {
restartingUntil = nil
button.title = isZapretRunning() ? "📳" : "📴"
}
button.image = nil
button.toolTip = currentStatusTitle()
}
}
private func statusImage(color: NSColor) -> NSImage {
let size = NSSize(width: 18, height: 18)
let image = NSImage(size: size)
image.lockFocus()
color.setFill()
NSBezierPath(ovalIn: NSRect(x: 1, y: 1, width: 16, height: 16)).fill()
NSColor.white.setFill()
drawHand(in: NSRect(x: 3.6, y: 3.2, width: 10.8, height: 12.4))
image.unlockFocus()
image.isTemplate = false
return image
}
private func drawHand(in rect: NSRect) {
let palm = NSBezierPath(roundedRect: NSRect(x: rect.minX + 2.8, y: rect.minY, width: 5.4, height: 6.6), xRadius: 2.2, yRadius: 2.2)
palm.fill()
let fingerWidth: CGFloat = 1.8
let gap: CGFloat = 0.45
let baseX = rect.minX + 1.2
let baseY = rect.minY + 5.1
let heights: [CGFloat] = [5.8, 7.2, 6.6, 5.2]
for index in 0..<4 {
let x = baseX + CGFloat(index) * (fingerWidth + gap)
let finger = NSBezierPath(roundedRect: NSRect(x: x, y: baseY, width: fingerWidth, height: heights[index]), xRadius: 0.9, yRadius: 0.9)
finger.fill()
}
let thumb = NSBezierPath()
thumb.move(to: NSPoint(x: rect.minX + 2.8, y: rect.minY + 4.3))
thumb.line(to: NSPoint(x: rect.minX + 0.1, y: rect.minY + 5.6))
thumb.line(to: NSPoint(x: rect.minX + 0.9, y: rect.minY + 7.3))
thumb.line(to: NSPoint(x: rect.minX + 3.7, y: rect.minY + 5.7))
thumb.close()
thumb.fill()
}
private func item(_ title: String, _ action: Selector) -> NSMenuItem {
let menuItem = NSMenuItem(title: title, action: action, keyEquivalent: "")
menuItem.target = self
return menuItem
}
private func currentStatusTitle() -> String {
if let until = restartingUntil, Date() < until {
return text("statusRestarting")
}
return isZapretRunning() ? text("statusRunning") : text("statusStopped")
}
private func isZapretRunning() -> Bool {
let result = runShell("/usr/bin/pgrep -x tpws >/dev/null && echo yes || echo no")
return result.trimmingCharacters(in: .whitespacesAndNewlines) == "yes"
}
private func isInternetReachable() -> Bool {
let output = runShell("""
if /sbin/ping -q -c 1 -W 1000 1.1.1.1 >/dev/null 2>&1; then
echo yes
elif /usr/bin/curl -Is --connect-timeout 2 --max-time 3 https://www.apple.com >/dev/null 2>&1; then
echo yes
else
echo no
fi
""")
return output.trimmingCharacters(in: .whitespacesAndNewlines) == "yes"
}
@objc private func startZapret() {
let result = runSudo("/opt/zapret/zapret-menu-helper start")
if result.success {
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: startTimeKey)
showNotification(text("started"))
} else {
showCommandError(result.output)
}
rebuildMenu()
}
@objc private func stopZapret() {
let result = runSudo("/opt/zapret/zapret-menu-helper stop")
if result.success {
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: stopTimeKey)
showNotification(text("stopped"))
} else {
showCommandError(result.output)
}
rebuildMenu()
}
@objc private func restartZapret() {
guard isZapretRunning() else {
showDialog(text("restartUnavailable"), title: "Zapret")
rebuildMenu()
return
}
guard isInternetReachable() else {
showDialog(text("restartNoInternet"), title: "Zapret")
rebuildMenu()
return
}
restartingUntil = Date().addingTimeInterval(3)
updateStatusIcon()
let result = runSudo("/opt/zapret/zapret-menu-helper restart")
restartingUntil = Date().addingTimeInterval(3)
if result.success {
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: startTimeKey)
showNotification(text("restarted"))
} else {
showCommandError(result.output)
}
rebuildMenu()
}
@objc private func updateHostlist() {
let before = hostlistLineCount()
let result = runSudo("/opt/zapret/zapret-menu-helper update")
let after = hostlistLineCount()
let updatedAt = hostlistModifiedTime()
let status = result.success ? text("hostlistUpdated") : text("hostlistUpdateFailed")
let message = """
\(status)
\(text("before")) \(before)
\(text("after")) \(after)
\(text("lastListUpdate")) \(updatedAt)
\(text("commandOutput"))
\(result.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? text("noOutput") : result.output)
"""
showDialog(message, title: "Zapret")
rebuildMenu()
}
@objc private func checkConnection() {
if isInternetReachable() {
showDialog(text("internetOk"), title: "Zapret")
} else {
showNoInternetDialog()
}
rebuildMenu()
}
@objc private func showStatus() {
showDialog(statusReport(), title: "Zapret")
rebuildMenu()
}
@objc private func showAbout() {
showDialog(aboutText(), title: "Zapret")
rebuildMenu()
}
@objc private func toggleLanguage() {
let newLanguage: Language = effectiveLanguage() == .ru ? .en : .ru
UserDefaults.standard.set(newLanguage.rawValue, forKey: languageKey)
rebuildMenu()
showNotification(text("languageChanged"))
}
@objc private func quit() {
_ = runSudo("/opt/zapret/zapret-menu-helper stop")
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: stopTimeKey)
let deadline = Date().addingTimeInterval(5)
while isZapretRunning() && Date() < deadline {
Thread.sleep(forTimeInterval: 0.25)
}
NSApp.terminate(nil)
}
private func runAdmin(_ command: String, success: String) {
let output = runShell("/usr/bin/sudo -n \(command) 2>&1")
if !output.contains("a password is required") && !output.contains("not in the sudoers") {
showNotification(success)
} else {
showDialog(text("passwordlessSetupMissing"), title: text("errorTitle"))
}
rebuildMenu()
}
private func showNoInternetDialog() {
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.messageText = text("internetFailTitle")
alert.informativeText = text("internetFailMessage")
if isZapretRunning() {
alert.addButton(withTitle: text("stop"))
}
alert.addButton(withTitle: text("close"))
let response = alert.runModal()
if response == .alertFirstButtonReturn, isZapretRunning() {
stopZapret()
}
}
private func runSudo(_ command: String) -> (success: Bool, output: String) {
let output = runShell("/usr/bin/sudo -n \(command) 2>&1; echo __EXIT_CODE__:$?")
let marker = "__EXIT_CODE__:"
guard let range = output.range(of: marker, options: .backwards) else {
return (false, output)
}
let codeText = output[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)
let commandOutput = String(output[..<range.lowerBound])
return (codeText == "0", commandOutput)
}
private func showCommandError(_ output: String) {
if output.contains("a password is required") || output.contains("not in the sudoers") {
showDialog(text("passwordlessSetupMissing"), title: text("errorTitle"))
} else {
showDialog(output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? text("commandFailed") : output, title: text("errorTitle"))
}
}
private func runShell(_ command: String) -> String {
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", command]
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8) ?? ""
} catch {
return error.localizedDescription
}
}
private func terminateOtherInstances() {
// Kept for compatibility with existing installs; lock file is authoritative.
}
private func acquireSingleInstanceLock() -> Bool {
let supportDirectory = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/Zapret Menu", isDirectory: true)
try? FileManager.default.createDirectory(at: supportDirectory, withIntermediateDirectories: true)
let lockPath = supportDirectory.appendingPathComponent("ZapretMenu.lock").path
lockFileDescriptor = open(lockPath, O_CREAT | O_RDWR, 0o600)
guard lockFileDescriptor >= 0 else {
return false
}
if flock(lockFileDescriptor, LOCK_EX | LOCK_NB) == 0 {
ftruncate(lockFileDescriptor, 0)
let pid = "\(getpid())\n"
_ = pid.withCString { write(lockFileDescriptor, $0, strlen($0)) }
return true
}
close(lockFileDescriptor)
lockFileDescriptor = -1
return false
}
private func hostlistLineCount() -> String {
runShell("/usr/bin/wc -l /opt/zapret/ipset/zapret-hosts.txt 2>/dev/null | /usr/bin/awk '{print $1}'").trimmingCharacters(in: .whitespacesAndNewlines)
}
private func userHostlistLineCount() -> String {
runShell("/usr/bin/wc -l /opt/zapret/ipset/zapret-hosts-user.txt 2>/dev/null | /usr/bin/awk '{print $1}'").trimmingCharacters(in: .whitespacesAndNewlines)
}
private func hostlistModifiedTime() -> String {
let output = runShell("/usr/bin/stat -f '%Sm' -t '%Y-%m-%d %H:%M:%S' /opt/zapret/ipset/zapret-hosts.txt 2>/dev/null")
return output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? text("unknown") : output.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func hostlistModifiedDate() -> String {
let output = runShell("/usr/bin/stat -f '%Sm' -t '%Y-%m-%d' /opt/zapret/ipset/zapret-hosts.txt 2>/dev/null")
return output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? text("unknown") : output.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func appModifiedDate() -> String {
guard let executablePath = Bundle.main.executablePath else {
return text("unknown")
}
let output = runShell("/usr/bin/stat -f '%Sm' -t '%Y-%m-%d' \(shellEscape(executablePath)) 2>/dev/null")
return output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? text("unknown") : output.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func zapretStartedAt() -> String {
let output = runShell("""
pid=$(/usr/bin/pgrep -x tpws | /usr/bin/head -n 1)
if [ -n "$pid" ]; then /bin/ps -o lstart= -p "$pid"; fi
""").trimmingCharacters(in: .whitespacesAndNewlines)
return output.isEmpty ? text("unknown") : output
}
private func zapretRuntime() -> String {
let output = runShell("""
pid=$(/usr/bin/pgrep -x tpws | /usr/bin/head -n 1)
if [ -n "$pid" ]; then /bin/ps -o etimes= -p "$pid" | /usr/bin/xargs; fi
""").trimmingCharacters(in: .whitespacesAndNewlines)
if let seconds = TimeInterval(output) {
return formatDuration(seconds)
}
return text("unknown")
}
private func timeSinceLastStop() -> String {
let timestamp = UserDefaults.standard.double(forKey: stopTimeKey)
if timestamp <= 0 {
return text("never")
}
return formatDuration(Date().timeIntervalSince1970 - timestamp)
}
private func lastStopTime() -> String {
let timestamp = UserDefaults.standard.double(forKey: stopTimeKey)
if timestamp <= 0 {
return text("never")
}
return formatDate(Date(timeIntervalSince1970: timestamp))
}
private func lastStartTime() -> String {
let timestamp = UserDefaults.standard.double(forKey: startTimeKey)
if timestamp <= 0 {
return text("unknown")
}
return formatDate(Date(timeIntervalSince1970: timestamp))
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = effectiveLanguage() == .ru ? Locale(identifier: "ru_RU") : Locale(identifier: "en_US")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter.string(from: date)
}
private func formatDateOnly(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = effectiveLanguage() == .ru ? Locale(identifier: "ru_RU") : Locale(identifier: "en_US")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private func statusReport() -> String {
let running = isZapretRunning()
let restarting = restartingUntil.map { Date() < $0 } ?? false
let statusLine = restarting ? text("humanRestarting") : (running ? text("humanRunning") : text("humanStopped"))
let connectionLine = isInternetReachable() ? text("internetReachable") : text("internetUnreachable")
let runtimeLine = running
? "\(text("runningSince")) \(zapretStartedAt())\n\(text("runningFor")) \(zapretRuntime())"
: "\(text("lastStoppedAt")) \(lastStopTime())\n\(text("stoppedFor")) \(timeSinceLastStop())"
let restartLine = running && isInternetReachable() ? text("restartAvailable") : text("restartBlocked")
return """
\(statusLine)
\(connectionLine)
\(runtimeLine)
\(text("listsBlock"))
\(text("lastListUpdate")) \(hostlistModifiedTime())
\(text("mainListSize")) \(hostlistLineCount()) \(text("lines"))
\(text("userListSize")) \(userHostlistLineCount()) \(text("lines"))
\(text("startupBlock"))
\(text("menuAutostart"))
\(text("zapretNoAutostart"))
\(text("actionsBlock"))
\(restartLine)
\(text("connectionCheckHint"))
"""
}
private func aboutText() -> String {
"""
\(text("aboutTitle"))
\(text("aboutDate")) \(formatDateOnly(Date()))
\(text("aboutAppUpdated")) \(appModifiedDate())
\(text("aboutListsUpdated")) \(hostlistModifiedDate())
\(text("aboutWhat"))
\(text("aboutHowToUse"))
\(text("aboutStart"))
\(text("aboutStop"))
\(text("aboutRestart"))
\(text("aboutConnection"))
\(text("aboutUpdate"))
\(text("aboutQuit"))
"""
}
private func formatDuration(_ seconds: TimeInterval) -> String {
let total = max(0, Int(seconds))
let days = total / 86400
let hours = (total % 86400) / 3600
let minutes = (total % 3600) / 60
let secs = total % 60
if days > 0 {
return "\(days)d \(hours)h \(minutes)m"
}
if hours > 0 {
return "\(hours)h \(minutes)m"
}
if minutes > 0 {
return "\(minutes)m \(secs)s"
}
return "\(secs)s"
}
private func shellEscape(_ value: String) -> String {
"'\(value.replacingOccurrences(of: "'", with: "'\\''"))'"
}
private func showDialog(_ message: String, title: String) {
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "OK")
alert.runModal()
}
private func showNotification(_ message: String) {
let content = UNMutableNotificationContent()
content.title = "Zapret"
content.body = message
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
private func selectedLanguage() -> Language {
let rawValue = UserDefaults.standard.string(forKey: languageKey) ?? Language.auto.rawValue
return Language(rawValue: rawValue) ?? .auto
}
private func effectiveLanguage() -> Language {
let selected = selectedLanguage()
if selected != .auto {
return selected
}
let systemCode = Locale.preferredLanguages.first?.lowercased() ?? ""
return systemCode.hasPrefix("ru") ? .ru : .en
}
private func languageMenuTitle() -> String {
let current = effectiveLanguage() == .ru ? "Русский" : "English"
let next = effectiveLanguage() == .ru ? "English" : "Русский"
return "\(text("switchLanguage")): \(current)\(next)"
}
private func text(_ key: String) -> String {
let ru: [String: String] = [
"start": "📳 Запустить",
"stop": "📴 Остановить",
"restart": "🔀 Перезапустить",
"updateHostlist": "🔂 Обновить список",
"checkConnection": "📶 Проверить соединение",
"showStatus": "▶ Показать статус",
"about": "ℹ️ О программе",
"switchLanguage": "Переключить язык",
"quit": "✖ Выключить программу",
"statusRunning": "Статус: запущен",
"statusStopped": "Статус: остановлен",
"statusRestarting": "Статус: перезапускается",
"started": "Zapret запущен",
"stopped": "Zapret остановлен",
"restarted": "Zapret перезапущен",
"hostlistUpdated": "Список обновлён",
"restartUnavailable": "Перезапуск доступен только когда zapret уже запущен. Сейчас соединение выключено.",
"restartNoInternet": "Перезапуск заблокирован: интернет-соединение не проходит проверку.",
"zapretRunningLine": "Zapret: запущен",
"zapretStoppedLine": "Zapret: остановлен",
"mainHostlist": "Основной список:",
"userHostlist": "Пользовательский список:",
"humanRunning": "📳 Zapret включён. Соединение сейчас работает через правила zapret.",
"humanStopped": "📴 Zapret выключен. Дополнительные правила обхода сейчас не применяются.",
"humanRestarting": "🔀 Zapret перезапускается. Подождите несколько секунд, пока правила применятся заново.",
"internetReachable": "📶 Интернет доступен: проверка соединения проходит.",
"internetUnreachable": "📶 Интернет недоступен: проверка соединения не проходит.",
"runningSince": "Запущен:",
"runningFor": "Работает уже:",
"lastStoppedAt": "Последняя остановка:",
"stoppedFor": "Выключен уже:",
"listsBlock": "Списки обхода:",
"mainListSize": "Основной список:",
"userListSize": "Ваш ручной список:",
"startupBlock": "Автозапуск:",
"menuAutostart": "• меню Zapret запускается вместе с macOS",
"zapretNoAutostart": "• сам zapret после перезагрузки остаётся выключенным",
"actionsBlock": "Действия:",
"restartAvailable": "• 🔀 Перезапуск доступен, потому что zapret включён",
"restartBlocked": "• 🔀 Перезапуск заблокирован: zapret выключен или нет интернет-соединения",
"connectionCheckHint": "• 📶 Проверка соединения проверяет доступность интернета, а не включает zapret",
"startedAt": "Запущен:",
"notRunning": "не запущен",
"timeSinceLastStop": "Прошло с последней остановки:",
"lastListUpdate": "Последнее обновление списка:",
"lines": "строк",
"unknown": "неизвестно",
"never": "никогда",
"before": "Было строк:",
"after": "Стало строк:",
"commandOutput": "Вывод команды:",
"noOutput": "без вывода",
"hostlistUpdateFailed": "Обновление списка завершилось с ошибкой",
"statusUnavailable": "Статус недоступен",
"commandFailed": "Команда не выполнена",
"errorTitle": "Ошибка Zapret",
"passwordlessSetupMissing": "Нет разрешения запускать zapret без пароля. Нужно один раз настроить правило sudoers.",
"languageChanged": "Язык интерфейса переключён",
"internetOk": "Интернет доступен.",
"internetFailTitle": "Интернет недоступен",
"internetFailMessage": "Не удалось подключиться к проверочным адресам. Можно остановить zapret или закрыть окно и проверить Wi‑Fi/VPN/DNS вручную.",
"aboutTitle": "Zapret Menu — управление zapret из верхнего меню macOS.",
"aboutDate": "Текущая дата:",
"aboutAppUpdated": "Последнее обновление программы:",
"aboutListsUpdated": "Последнее обновление списков доступа:",
"aboutWhat": "Приложение управляет локальной установкой zapret: запускает, останавливает, перезапускает сервис и обновляет списки обхода.",
"aboutHowToUse": "Как пользоваться:",
"aboutStart": "• 📳 Запустить — включает zapret.",
"aboutStop": "• 📴 Остановить — выключает zapret и очищает правила.",
"aboutRestart": "• 🔀 Перезапустить — доступно только когда zapret уже включён и интернет проходит проверку.",
"aboutConnection": "• 📶 Проверить соединение — проверяет доступность интернета через ping 1.1.1.1 и HTTPS-запрос к apple.com.",
"aboutUpdate": "• 🔂 Обновить список — скачивает свежий список доменов обхода.",
"aboutQuit": "• ✖ Выключить программу — сначала останавливает zapret, затем закрывает меню.",
"close": "Закрыть"
]
let en: [String: String] = [
"start": "📳 Start",
"stop": "📴 Stop",
"restart": "🔀 Restart",
"updateHostlist": "🔂 Update Hostlist",
"checkConnection": "📶 Check Connection",
"showStatus": "▶ Show Status",
"about": "ℹ️ About",
"switchLanguage": "Switch Language",
"quit": "✖ Quit",
"statusRunning": "Status: running",
"statusStopped": "Status: stopped",
"statusRestarting": "Status: restarting",
"started": "Zapret started",
"stopped": "Zapret stopped",
"restarted": "Zapret restarted",
"hostlistUpdated": "Hostlist updated",
"restartUnavailable": "Restart is only available when zapret is already running. The connection is currently off.",
"restartNoInternet": "Restart is blocked: the internet connection check is failing.",
"zapretRunningLine": "Zapret: running",
"zapretStoppedLine": "Zapret: stopped",
"mainHostlist": "Main hostlist:",
"userHostlist": "User hostlist:",
"humanRunning": "📳 Zapret is on. The connection is currently using zapret rules.",
"humanStopped": "📴 Zapret is off. Bypass rules are not applied right now.",
"humanRestarting": "🔀 Zapret is restarting. Wait a few seconds while the rules are applied again.",
"internetReachable": "📶 Internet is reachable: connection check passes.",
"internetUnreachable": "📶 Internet is unreachable: connection check fails.",
"runningSince": "Started:",
"runningFor": "Running for:",
"lastStoppedAt": "Last stopped:",
"stoppedFor": "Stopped for:",
"listsBlock": "Bypass lists:",
"mainListSize": "Main list:",
"userListSize": "Your manual list:",
"startupBlock": "Startup:",
"menuAutostart": "• Zapret Menu starts with macOS",
"zapretNoAutostart": "• zapret itself stays off after reboot",
"actionsBlock": "Actions:",
"restartAvailable": "• 🔀 Restart is available because zapret is on",
"restartBlocked": "• 🔀 Restart is blocked: zapret is off or internet is unavailable",
"connectionCheckHint": "• 📶 Check Connection verifies internet reachability; it does not turn zapret on",
"startedAt": "Started at:",
"notRunning": "not running",
"timeSinceLastStop": "Time since last stop:",
"lastListUpdate": "Last hostlist update:",
"lines": "lines",
"unknown": "unknown",
"never": "never",
"before": "Before:",
"after": "After:",
"commandOutput": "Command output:",
"noOutput": "no output",
"hostlistUpdateFailed": "Hostlist update failed",
"statusUnavailable": "Status unavailable",
"commandFailed": "Command failed",
"errorTitle": "Zapret Error",
"passwordlessSetupMissing": "No permission to run zapret without a password. Configure the sudoers rule once.",
"languageChanged": "Interface language switched",
"internetOk": "Internet is available.",
"internetFailTitle": "Internet unavailable",
"internetFailMessage": "The test addresses are unreachable. You can stop zapret or close this window and check Wi-Fi/VPN/DNS manually.",
"aboutTitle": "Zapret Menu — control zapret from the macOS menu bar.",
"aboutDate": "Current date:",
"aboutAppUpdated": "Last app update:",
"aboutListsUpdated": "Last access list update:",
"aboutWhat": "The app controls the local zapret installation: start, stop, restart, and hostlist update.",
"aboutHowToUse": "How to use:",
"aboutStart": "• 📳 Start — turns zapret on.",
"aboutStop": "• 📴 Stop — turns zapret off and clears rules.",
"aboutRestart": "• 🔀 Restart — available only when zapret is already on and the internet check passes.",
"aboutConnection": "• 📶 Check Connection — checks internet reachability using ping to 1.1.1.1 and an HTTPS request to apple.com.",
"aboutUpdate": "• 🔂 Update Hostlist — downloads a fresh bypass domain list.",
"aboutQuit": "• ✖ Quit — stops zapret first, then closes the menu app.",
"close": "Close"
]
let dictionary = effectiveLanguage() == .ru ? ru : en
return dictionary[key] ?? key
}
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()

29
extras/macos-menu/build.sh

@ -0,0 +1,29 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
APP_NAME="Zapret Menu.app"
BUILD_DIR="${SCRIPT_DIR}/build"
APP_DIR="${BUILD_DIR}/${APP_NAME}"
MACOS_DIR="${APP_DIR}/Contents/MacOS"
RESOURCES_DIR="${APP_DIR}/Contents/Resources"
command -v swiftc >/dev/null 2>&1 || {
echo "swiftc is required. Install Xcode Command Line Tools first." >&2
exit 1
}
rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp "$SCRIPT_DIR/Info.plist" "$APP_DIR/Contents/Info.plist"
cp "$SCRIPT_DIR/Resources/ZapretIcon.icns" "$RESOURCES_DIR/ZapretIcon.icns"
swiftc "$SCRIPT_DIR/Sources/ZapretMenu.swift" \
-o "$MACOS_DIR/Zapret Menu" \
-framework Cocoa
chmod +x "$MACOS_DIR/Zapret Menu"
touch "$APP_DIR"
echo "$APP_DIR"

75
extras/macos-menu/install.sh

@ -0,0 +1,75 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
APP_NAME="Zapret Menu.app"
ZAPRET_BASE=${ZAPRET_BASE:-/opt/zapret}
INSTALL_DIR=${INSTALL_DIR:-"$HOME/Applications/Zapret Control"}
APP_PATH="$INSTALL_DIR/$APP_NAME"
LAUNCH_AGENT="$HOME/Library/LaunchAgents/org.zapret.menu.plist"
SUDOERS_FILE="/etc/sudoers.d/zapret-menu"
HELPER_SRC="$SCRIPT_DIR/zapret-menu-helper"
HELPER_DST="$ZAPRET_BASE/zapret-menu-helper"
[ "$(uname)" = "Darwin" ] || {
echo "This menu app is macOS-only." >&2
exit 1
}
[ -d "$ZAPRET_BASE" ] || {
echo "zapret is not installed at $ZAPRET_BASE. Run install_easy.sh first or set ZAPRET_BASE." >&2
exit 1
}
APP_BUILT=$("$SCRIPT_DIR/build.sh")
mkdir -p "$INSTALL_DIR"
rm -rf "$APP_PATH"
cp -R "$APP_BUILT" "$APP_PATH"
xattr -dr com.apple.quarantine "$APP_PATH" 2>/dev/null || true
echo "Installing privileged helper and sudoers rule. You may be asked for your macOS password."
sudo install -m 0755 -o root -g wheel "$HELPER_SRC" "$HELPER_DST"
TMP_SUDOERS=$(mktemp)
CURRENT_USER=$(id -un)
cat >"$TMP_SUDOERS" <<EOF
$CURRENT_USER ALL=(root) NOPASSWD: $HELPER_DST start, $HELPER_DST stop, $HELPER_DST restart, $HELPER_DST update
EOF
sudo visudo -cf "$TMP_SUDOERS"
sudo install -m 0440 -o root -g wheel "$TMP_SUDOERS" "$SUDOERS_FILE"
rm -f "$TMP_SUDOERS"
mkdir -p "$HOME/Library/LaunchAgents"
cat >"$LAUNCH_AGENT" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.zapret.menu</string>
<key>ProgramArguments</key>
<array>
<string>$APP_PATH/Contents/MacOS/Zapret Menu</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>StandardOutPath</key>
<string>/tmp/zapret-menu.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/zapret-menu.err.log</string>
</dict>
</plist>
EOF
launchctl bootout "gui/$(id -u)" "$LAUNCH_AGENT" 2>/dev/null || true
pkill -x "Zapret Menu" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$LAUNCH_AGENT"
echo "Installed: $APP_PATH"
echo "LaunchAgent: $LAUNCH_AGENT"
echo "Helper: $HELPER_DST"
echo "sudoers: $SUDOERS_FILE"

25
extras/macos-menu/uninstall.sh

@ -0,0 +1,25 @@
#!/bin/sh
set -eu
ZAPRET_BASE=${ZAPRET_BASE:-/opt/zapret}
INSTALL_DIR=${INSTALL_DIR:-"$HOME/Applications/Zapret Control"}
APP_PATH="$INSTALL_DIR/Zapret Menu.app"
LAUNCH_AGENT="$HOME/Library/LaunchAgents/org.zapret.menu.plist"
SUDOERS_FILE="/etc/sudoers.d/zapret-menu"
HELPER_DST="$ZAPRET_BASE/zapret-menu-helper"
[ "$(uname)" = "Darwin" ] || {
echo "This menu app is macOS-only." >&2
exit 1
}
launchctl bootout "gui/$(id -u)" "$LAUNCH_AGENT" 2>/dev/null || true
pkill -x "Zapret Menu" 2>/dev/null || true
rm -f "$LAUNCH_AGENT"
rm -rf "$APP_PATH"
echo "Removing privileged helper and sudoers rule. You may be asked for your macOS password."
sudo rm -f "$HELPER_DST" "$SUDOERS_FILE"
echo "Zapret Menu removed."

35
extras/macos-menu/zapret-menu-helper

@ -0,0 +1,35 @@
#!/bin/sh
set -eu
ZAPRET_BASE=${ZAPRET_BASE:-/opt/zapret}
stop_tpws() {
pids=$(/usr/bin/pgrep -x tpws 2>/dev/null || true)
if [ -n "$pids" ]; then
/bin/kill $pids 2>/dev/null || true
/bin/sleep 1
pids=$(/usr/bin/pgrep -x tpws 2>/dev/null || true)
[ -z "$pids" ] || /bin/kill -9 $pids 2>/dev/null || true
fi
}
case "${1:-}" in
start)
"$ZAPRET_BASE/init.d/macos/zapret" start
;;
stop)
"$ZAPRET_BASE/init.d/macos/zapret" stop || true
stop_tpws
;;
restart)
"$ZAPRET_BASE/zapret-menu-helper" stop
"$ZAPRET_BASE/zapret-menu-helper" start
;;
update)
"$ZAPRET_BASE/ipset/get_refilter_domains.sh"
;;
*)
echo "Usage: $0 {start|stop|restart|update}" >&2
exit 64
;;
esac
Loading…
Cancel
Save