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 00000000..d7fab23b Binary files /dev/null and b/extras/macos-menu/Resources/ZapretIcon.icns differ diff --git a/extras/macos-menu/Sources/ZapretMenu.swift b/extras/macos-menu/Sources/ZapretMenu.swift new file mode 100644 index 00000000..75df1212 --- /dev/null +++ b/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[.. 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