From e8dc7e5bcb68850028ea43188746bc0922c8d257 Mon Sep 17 00:00:00 2001 From: pitk150-alt Date: Thu, 7 May 2026 02:05:10 +0300 Subject: [PATCH] macos: add optional menu bar controller Co-authored-by: Cursor --- extras/macos-menu/Info.plist | 24 + extras/macos-menu/README.md | 124 ++++ extras/macos-menu/Resources/ZapretIcon.icns | Bin 0 -> 63761 bytes extras/macos-menu/Sources/ZapretMenu.swift | 731 ++++++++++++++++++++ extras/macos-menu/build.sh | 29 + extras/macos-menu/install.sh | 75 ++ extras/macos-menu/uninstall.sh | 25 + extras/macos-menu/zapret-menu-helper | 35 + 8 files changed, 1043 insertions(+) create mode 100644 extras/macos-menu/Info.plist create mode 100644 extras/macos-menu/README.md create mode 100644 extras/macos-menu/Resources/ZapretIcon.icns create mode 100644 extras/macos-menu/Sources/ZapretMenu.swift create mode 100755 extras/macos-menu/build.sh create mode 100755 extras/macos-menu/install.sh create mode 100755 extras/macos-menu/uninstall.sh create mode 100755 extras/macos-menu/zapret-menu-helper diff --git a/extras/macos-menu/Info.plist b/extras/macos-menu/Info.plist new file mode 100644 index 00000000..2c418f9d --- /dev/null +++ b/extras/macos-menu/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleExecutable + Zapret Menu + CFBundleIdentifier + org.zapret.menu + CFBundleName + Zapret Menu + CFBundleDisplayName + Zapret Menu + CFBundleIconFile + ZapretIcon + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSUIElement + + + diff --git a/extras/macos-menu/README.md b/extras/macos-menu/README.md new file mode 100644 index 00000000..ef8cc3ae --- /dev/null +++ b/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 +``` diff --git a/extras/macos-menu/Resources/ZapretIcon.icns b/extras/macos-menu/Resources/ZapretIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..d7fab23b632c80b2d47adc95005410e52456256f GIT binary patch literal 63761 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 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[.. 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() diff --git a/extras/macos-menu/build.sh b/extras/macos-menu/build.sh new file mode 100755 index 00000000..1b4906c6 --- /dev/null +++ b/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" diff --git a/extras/macos-menu/install.sh b/extras/macos-menu/install.sh new file mode 100755 index 00000000..d712f11b --- /dev/null +++ b/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" <"$LAUNCH_AGENT" < + + + + Label + org.zapret.menu + ProgramArguments + + $APP_PATH/Contents/MacOS/Zapret Menu + + RunAtLoad + + KeepAlive + + LimitLoadToSessionType + Aqua + StandardOutPath + /tmp/zapret-menu.out.log + StandardErrorPath + /tmp/zapret-menu.err.log + + +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" diff --git a/extras/macos-menu/uninstall.sh b/extras/macos-menu/uninstall.sh new file mode 100755 index 00000000..6de4cc3c --- /dev/null +++ b/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." diff --git a/extras/macos-menu/zapret-menu-helper b/extras/macos-menu/zapret-menu-helper new file mode 100755 index 00000000..8593b130 --- /dev/null +++ b/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