mirror of https://github.com/bol-van/zapret/
8 changed files with 1043 additions and 0 deletions
@ -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> |
|||
@ -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 |
|||
``` |
|||
Binary file not shown.
@ -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() |
|||
@ -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" |
|||
@ -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" |
|||
@ -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." |
|||
@ -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…
Reference in new issue