Move the iOS app to its own GHOSTWIRE-Companion repo

This commit is contained in:
Daniel Redetzke
2026-10-04 12:10:45 +03:00
parent 8bc2440e40
commit b0be0b0ceb
34 changed files with 6 additions and 3442 deletions
-1
View File
@@ -1,7 +1,6 @@
/GHOSTWIRE /GHOSTWIRE
/dist/ /dist/
/dev/ /dev/
/ios/build/
xcuserdata/ xcuserdata/
*.xcuserstate *.xcuserstate
.DS_Store .DS_Store
+6 -11
View File
@@ -244,17 +244,12 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
## iOS app ## iOS app
`ios/` holds the native iPhone app (SwiftUI, iOS 17+). It does everything the The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
web interface does except password, API tokens and backups. Pair it in the web GHOSTWIRE-Companion. It does everything the web interface does except
interface under Settings → Pair iOS app: scan the QR code, or tap "Copy pairing password, API tokens and backups. Pair it in the web interface under
code" and paste it into the app's "Enter manually". Self-signed certificates are Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
pinned during pairing. it into the app's "Enter manually". Self-signed certificates are pinned during
pairing.
- Open `ios/GHOSTWIRE.xcodeproj` in Xcode to build and run.
- `TEAM_ID=<your team> ios/release.sh` archives and uploads a build to App Store
Connect.
- `ios/AppStore/` has the store listing text, privacy details, review notes and
6.9-inch screenshots.
## Development ## Development
-90
View File
@@ -1,90 +0,0 @@
# GHOSTWIRE – App Store listing
Copy these into App Store Connect. Fields marked **[YOU]** need your input.
## App information
| Field | Value |
|---|---|
| Name | GHOSTWIRE |
| Subtitle (30 chars) | Manage your own WireGuard VPN |
| Bundle ID | aero.redetzke.ghostwire |
| SKU | ghostwire-ios |
| Primary category | Utilities |
| Secondary category | Developer Tools |
| Age rating | 4+ (answer "None" to every question) |
| Price | **[YOU]** (free suggested) |
| Support URL | **[YOU]** e.g. https://git.redetzke.aero/Redetzke/GHOSTWIRE |
| Privacy policy URL | **[YOU]** host the text from "Privacy policy" below |
| Copyright | **[YOU]** e.g. 2026 Daniel Redetzke |
## Promotional text (170 chars)
Your WireGuard® server in your pocket: see who is online, add devices with a QR code and watch traffic per device – all on your own server, nothing in between.
## Description
GHOSTWIRE is the companion app for the GHOSTWIRE server manager, a small program that sets up and runs a WireGuard® VPN server on your own Linux machine.
Pair the app once by scanning a QR code in the GHOSTWIRE web interface. From then on you can:
• See at a glance which devices are online and how much they transfer
• Add a device and show its config as a QR code to scan with the WireGuard app
• Issue a new config when a phone is replaced – the old one stops working
• Disable or delete devices instantly
• Follow traffic per device over 24 hours, 7 and 30 days
• Change the server's port, networks, DNS, routing and firewall options
• Check the server's health and read its log
• Set log and traffic history retention
Private by design:
• The app talks only to your server – there is no cloud service and no account.
• It signs in with a token you can revoke at any time.
• Self-signed certificates are pinned during pairing; Let's Encrypt certificates are checked normally.
• No analytics, no tracking, no data collection.
Requires a GHOSTWIRE server (Linux with kernel 5.6 or newer).
WireGuard is a registered trademark of Jason A. Donenfeld. GHOSTWIRE is not affiliated with or endorsed by the WireGuard project.
## Keywords (100 chars)
wireguard,vpn,server,admin,peers,qr,self-hosted,homelab,tunnel,network,raspberry pi
## What's new (1.0)
First release.
## App privacy (App Store Connect → App Privacy)
Data collection: **No, we do not collect data from this app.**
## Export compliance
The app only uses HTTPS (Apple's built-in TLS). `ITSAppUsesNonExemptEncryption` is set to NO in the build, so App Store Connect does not ask again.
## Privacy policy
> GHOSTWIRE (the iOS app) does not collect, store or share any personal data. The app connects only to the GHOSTWIRE server that you pair it with; the server address and access token are stored in the iOS keychain on your device. No data is sent to the developer or to third parties. Removing the app or tapping "Disconnect this iPhone" deletes the stored pairing.
## App Review information
Reviewers cannot use the app without a server. Provide a demo server:
1. Run GHOSTWIRE on a public test server with a Let's Encrypt certificate.
2. Add a few demo peers.
3. In the web interface: Settings → Pair iOS app → name "App Review", access "Full access" → **Copy pairing code**.
Notes for the reviewer (paste into "Notes"):
> GHOSTWIRE manages a self-hosted WireGuard VPN server. To review: open the app, tap "Enter manually", paste the pairing code below into "Pairing code" and tap Connect. You can then browse the dashboard, peers and server settings. Adding a peer shows a QR code for the WireGuard app; this demo server does not route real traffic.
>
> Pairing code: **[YOU: paste the pairing code]**
Sign-in required: **No** (pairing code instead of an account). Demo account fields: leave empty.
Revoke the "App Review" token after approval.
## Screenshots
`screenshots/` holds 6.9-inch iPhone screenshots (1320 × 2868), the size App Store Connect requires; it scales them down for smaller iPhones. Upload them in file-name order.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

-18
View File
@@ -1,18 +0,0 @@
<?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>method</key>
<string>app-store-connect</string>
<key>destination</key>
<string>__DEST__</string>
<key>signingStyle</key>
<string>automatic</string>
<key>teamID</key>
<string>__TEAM_ID__</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
-256
View File
@@ -1,256 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
A10000000000000000000002 /* GHOSTWIRE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GHOSTWIRE.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A10000000000000000000003 /* GHOSTWIRE */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = GHOSTWIRE;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
A10000000000000000000010 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
A10000000000000000000001 = {
isa = PBXGroup;
children = (
A10000000000000000000003 /* GHOSTWIRE */,
A10000000000000000000004 /* Products */,
);
sourceTree = "<group>";
};
A10000000000000000000004 /* Products */ = {
isa = PBXGroup;
children = (
A10000000000000000000002 /* GHOSTWIRE.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A10000000000000000000005 /* GHOSTWIRE */ = {
isa = PBXNativeTarget;
buildConfigurationList = A10000000000000000000020 /* Build configuration list for PBXNativeTarget "GHOSTWIRE" */;
buildPhases = (
A10000000000000000000011 /* Sources */,
A10000000000000000000010 /* Frameworks */,
A10000000000000000000012 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
A10000000000000000000003 /* GHOSTWIRE */,
);
name = GHOSTWIRE;
packageProductDependencies = (
);
productName = GHOSTWIRE;
productReference = A10000000000000000000002 /* GHOSTWIRE.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A10000000000000000000006 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2700;
LastUpgradeCheck = 2700;
TargetAttributes = {
A10000000000000000000005 = {
CreatedOnToolsVersion = 27.0;
};
};
};
buildConfigurationList = A10000000000000000000021 /* Build configuration list for PBXProject "GHOSTWIRE" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = A10000000000000000000001;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = A10000000000000000000004 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
A10000000000000000000005 /* GHOSTWIRE */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A10000000000000000000012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A10000000000000000000011 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A10000000000000000000030 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_OPTIMIZATION_LEVEL = 0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A10000000000000000000031 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A10000000000000000000032 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = SMHP65UGQ3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = GHOSTWIRE;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSCameraUsageDescription = "The camera scans the pairing QR code shown in the GHOSTWIRE web interface.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = aero.redetzke.ghostwire;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
A10000000000000000000033 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = SMHP65UGQ3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = GHOSTWIRE;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSCameraUsageDescription = "The camera scans the pairing QR code shown in the GHOSTWIRE web interface.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = aero.redetzke.ghostwire;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A10000000000000000000020 /* Build configuration list for PBXNativeTarget "GHOSTWIRE" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A10000000000000000000032 /* Debug */,
A10000000000000000000033 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A10000000000000000000021 /* Build configuration list for PBXProject "GHOSTWIRE" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A10000000000000000000030 /* Debug */,
A10000000000000000000031 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = A10000000000000000000006 /* Project object */;
}
-108
View File
@@ -1,108 +0,0 @@
import CryptoKit
import Foundation
enum APIError: LocalizedError {
case server(String)
case unauthorized
case badPairing(String)
var errorDescription: String? {
switch self {
case .server(let m): m
case .unauthorized: "This iPhone is no longer paired. Pair it again from Settings → Pair iOS app in the web interface."
case .badPairing(let m): m
}
}
}
/// Accepts the server only if its certificate matches the fingerprint from
/// the pairing code. Without a fingerprint (Let's Encrypt), normal system
/// trust applies.
nonisolated final class PinningDelegate: NSObject, URLSessionDelegate, Sendable {
let fingerprint: String
init(fingerprint: String) {
self.fingerprint = fingerprint.replacingOccurrences(of: ":", with: "").uppercased()
}
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async
-> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust,
!fingerprint.isEmpty else {
return (.performDefaultHandling, nil)
}
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let leaf = chain.first else {
return (.cancelAuthenticationChallenge, nil)
}
let digest = SHA256.hash(data: SecCertificateCopyData(leaf) as Data)
let hex = digest.map { String(format: "%02X", $0) }.joined()
return hex == fingerprint ? (.useCredential, URLCredential(trust: trust)) : (.cancelAuthenticationChallenge, nil)
}
}
/// Client for GHOSTWIRE's /api/v1, authenticated with the paired API token.
final class API {
let base: String
private let token: String
private let session: URLSession
init(pairing p: Pairing) {
var url = p.url.trimmingCharacters(in: .whitespacesAndNewlines)
while url.hasSuffix("/") { url.removeLast() }
base = url
token = p.token
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 15
session = URLSession(configuration: cfg, delegate: PinningDelegate(fingerprint: p.fingerprint), delegateQueue: nil)
}
static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .custom { dec in
let s = try dec.singleValueContainer().decode(String.self)
guard let date = parseGoDate(s) else {
throw DecodingError.dataCorrupted(.init(codingPath: dec.codingPath, debugDescription: "bad date \(s)"))
}
return date
}
return d
}()
/// Sends a request and returns the raw body. Body values of nil are sent
/// as JSON null ("use the server default").
func data(_ method: String, _ path: String, body: [String: Any?]? = nil) async throws -> Data {
guard let url = URL(string: base + "/api/v1" + path) else { throw APIError.badPairing("The server address is not valid.") }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: body.mapValues { $0 ?? NSNull() })
}
let (data, resp) = try await session.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 { throw APIError.unauthorized }
guard (200..<300).contains(code) else {
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
throw APIError.server(obj?["error"] as? String ?? "The server answered with HTTP \(code).")
}
return data
}
func get<T: Decodable>(_ path: String) async throws -> T {
try Self.decoder.decode(T.self, from: try await data("GET", path))
}
func send<T: Decodable>(_ method: String, _ path: String, _ body: [String: Any?]? = nil) async throws -> T {
try Self.decoder.decode(T.self, from: try await data(method, path, body: body))
}
/// Creates a peer or issues a config. The server answers with the config,
/// or with a setup link when the body asked for one.
func issue(_ path: String, _ body: [String: Any?]?) async throws -> IssueOutcome {
let d = try await data("POST", path, body: body)
if let l = try? Self.decoder.decode(LinkCreated.self, from: d) { return .link(l) }
return .config(try Self.decoder.decode(IssuedConfig.self, from: d))
}
}
@@ -1,23 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : { "alpha" : "1.000", "blue" : "0x1A", "green" : "0x17", "red" : "0x16" }
},
"idiom" : "universal"
},
{
"appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ],
"color" : {
"color-space" : "srgb",
"components" : { "alpha" : "1.000", "blue" : "0xEE", "green" : "0xF2", "red" : "0xF2" }
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

@@ -1,14 +0,0 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
-106
View File
@@ -1,106 +0,0 @@
import SwiftUI
struct DashboardView: View {
@Environment(AppSession.self) private var session
@State private var status: Status?
@State private var peers: [Peer] = []
@State private var points: [StatPoint] = []
@State private var error: String?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
if let error { Notice(text: error, isError: true) }
if let s = status {
content(s)
} else if error == nil {
ProgressView().padding(40)
}
}
.padding(16)
}
.background(Color.gwGround)
.navigationTitle("Dashboard")
.toolbar {
ToolbarItem(placement: .topBarLeading) { KamonMark(size: 30) }
}
.navigationDestination(for: String.self) { PeerDetailView(peerID: $0) }
.refreshable { await load() }
.task {
while !Task.isCancelled {
await load()
try? await Task.sleep(for: .seconds(30))
}
}
}
}
@ViewBuilder private func content(_ s: Status) -> some View {
let failing = s.checks.filter { !$0.ok }
let ifUp = s.checks.first { $0.name == "WireGuard interface" }?.ok ?? false
Text("Endpoint \(s.endpoint) · \(s.ipv4)")
.font(.mono(.caption))
.foregroundStyle(Color.gwText2)
.frame(maxWidth: .infinity, alignment: .leading)
if !failing.isEmpty {
Notice(text: "Needs attention: " + failing.map { "\($0.name) (\($0.detail))" }.joined(separator: " · "), isError: true)
}
// A Grid (not LazyVGrid) gives both tiles of a row the same height.
Grid(horizontalSpacing: 12, verticalSpacing: 12) {
GridRow {
Tile(title: "Peers online", value: "\(s.peers.online)", suffix: "/ \(s.peers.total)",
sub: "\(s.peers.disabled) disabled · \(s.peers.never) never connected")
Tile(title: "Interface", value: ifUp ? "Up" : "Down", dot: ifUp ? .gwGood : .gwBad,
sub: s.healthy ? "All checks pass" : "\(failing.count) check(s) failing")
}
GridRow {
Tile(title: "Last 24 h", value: fmtBytes(s.traffic24h.down + s.traffic24h.up),
sub: "Down \(fmtBytes(s.traffic24h.down)) · Up \(fmtBytes(s.traffic24h.up))")
Tile(title: "Last 30 days", value: fmtBytes(s.traffic30d.down + s.traffic30d.up),
sub: s.topPeer30d.isEmpty ? "No traffic yet" : "Top peer: \(s.topPeer30d)")
}
}
VStack(alignment: .leading, spacing: 10) {
SectionTitle(text: "Traffic, all peers · 24 h")
TrafficChart(points: points, range: "24h", mode: .total)
}
.card()
VStack(alignment: .leading, spacing: 0) {
SectionTitle(text: "Peers").padding(.bottom, 8)
let top = peers.sorted { $0.stats.down24h + $0.stats.up24h > $1.stats.down24h + $1.stats.up24h }.prefix(6)
if top.isEmpty {
Text("No peers yet.").font(.footnote).foregroundStyle(Color.gwText2).padding(.vertical, 8)
}
ForEach(Array(top)) { p in
NavigationLink(value: p.id) {
PeerRow(peer: p, period: .day)
}
.buttonStyle(.plain)
if p.id != top.last?.id { Divider() }
}
}
.card()
}
private func load() async {
guard let api = session.api else { return }
do {
async let s: Status = api.get("/status")
async let p: PeerList = api.get("/peers")
async let st: StatsResponse = api.get("/stats?range=24h")
let (a, b, c) = try await (s, p, st)
status = a
peers = b.peers
points = c.points
error = nil
} catch {
self.error = session.message(for: error)
}
}
}
-97
View File
@@ -1,97 +0,0 @@
import Foundation
/// Bytes in decimal units, as the web UI shows them: "11.2 MB".
nonisolated func fmtBytes(_ n: Int64) -> String {
let units = ["B", "KB", "MB", "GB", "TB", "PB"]
var v = Double(n)
var i = 0
while v >= 1000 && i < units.count - 1 {
v /= 1000
i += 1
}
let s: String
if i == 0 { s = String(Int(v)) }
else if v < 10 { s = String(format: "%.2f", v) }
else if v < 100 { s = String(format: "%.1f", v) }
else { s = String(Int(v.rounded())) }
return s + " " + units[i]
}
func ago(_ date: Date?) -> String {
guard let date else { return "never" }
let s = max(0, Date().timeIntervalSince(date))
switch s {
case ..<60: return "\(Int(s)) s ago"
case ..<3600: return "\(Int(s / 60)) min ago"
case ..<86400: return "\(Int(s / 3600)) h ago"
default: return "\(Int(s / 86400)) d ago"
}
}
func fmtDate(_ date: Date?) -> String {
guard let date, date.timeIntervalSince1970 > 0 else { return "–" }
return date.formatted(date: .abbreviated, time: .omitted)
}
func fmtStamp(_ date: Date) -> String {
date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
}
/// "35 min", "2 h 5 min", "3 days".
func fmtDuration(_ seconds: Int64) -> String {
if seconds < 60 { return "under 1 min" }
let m = Int((Double(seconds) / 60).rounded())
if m < 60 { return "\(m) min" }
let h = m / 60
if h < 48 { return "\(h) h \(m % 60) min" }
return "\(Int((Double(h) / 24).rounded())) days"
}
/// Label of a chart point: "3 h ago" for hours, "Sat 3 Oct" for days.
func pointLabel(_ p: StatPoint, range: String) -> String {
if range == "24h" {
let h = Int((Date().timeIntervalSince(p.date) / 3600).rounded(.down))
return h <= 0 ? "This hour" : "\(h) h ago"
}
return p.date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated))
}
/// Parses Go's RFC 3339 times, which carry up to nine fractional digits.
nonisolated func parseGoDate(_ s: String) -> Date? {
var str = s
if let dot = str.firstIndex(of: "."),
let end = str[dot...].firstIndex(where: { $0 == "Z" || $0 == "+" || $0 == "-" }) {
let frac = str[str.index(after: dot)..<end]
let ms = String(frac.prefix(3)).padding(toLength: 3, withPad: "0", startingAt: 0)
str = String(str[..<dot]) + "." + ms + String(str[end...])
}
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = f.date(from: str) { return d }
f.formatOptions = [.withInternetDateTime]
return f.date(from: s)
}
/// Splits "a, b,c" into ["a", "b", "c"].
func splitList(_ s: String) -> [String] {
s.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
/// Formats one JSON log record like the web UI: time, level, message, details.
func formatLogLine(_ rec: [String: Any]) -> String {
var ts = rec["time"] as? String ?? ""
if let d = parseGoDate(ts) {
ts = d.formatted(.dateTime.year().month(.twoDigits).day(.twoDigits).hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits))
}
let level = (rec["level"] as? String ?? "").padding(toLength: 5, withPad: " ", startingAt: 0)
let msg = rec["msg"] as? String ?? ""
let rest = rec.keys.filter { !["time", "level", "msg", "audit"].contains($0) }.sorted().map { k -> String in
let v = rec[k]
if let s = v as? String { return "\(k)=\(s)" }
if let v, let d = try? JSONSerialization.data(withJSONObject: v, options: [.fragmentsAllowed]), let s = String(data: d, encoding: .utf8) {
return "\(k)=\(s)"
}
return "\(k)=?"
}.joined(separator: " ")
return "\(ts) \(level) \(msg)" + (rest.isEmpty ? "" : " " + rest)
}
-65
View File
@@ -1,65 +0,0 @@
import SwiftUI
@main
struct GhostwireApp: App {
@State private var session = AppSession()
var body: some Scene {
WindowGroup {
RootView()
.environment(session)
.tint(Color.gwAccent)
}
}
}
struct RootView: View {
@Environment(AppSession.self) private var session
var body: some View {
@Bindable var session = session
Group {
if session.api == nil {
PairingView()
} else {
MainTabView()
}
}
.alert("GHOSTWIRE", isPresented: Binding(get: { session.alert != nil }, set: { if !$0 { session.alert = nil } })) {
Button("OK", role: .cancel) {}
} message: {
Text(session.alert ?? "")
}
}
}
struct MainTabView: View {
enum Tab: String { case dashboard, peers, server, settings }
@Environment(AppSession.self) private var session
@State private var tab: Tab = {
#if DEBUG
// Development: `-tab peers` opens a tab directly.
if let t = UserDefaults.standard.string(forKey: "tab"), let tab = Tab(rawValue: t) { return tab }
#endif
return .dashboard
}()
var body: some View {
TabView(selection: $tab) {
DashboardView()
.tabItem { Label("Dashboard", systemImage: "square.grid.2x2") }
.tag(Tab.dashboard)
PeersView()
.tabItem { Label("Peers", systemImage: "person.2") }
.tag(Tab.peers)
ServerView()
.tabItem { Label("Server", systemImage: "server.rack") }
.tag(Tab.server)
SettingsView()
.tabItem { Label("Settings", systemImage: "slider.horizontal.3") }
.tag(Tab.settings)
}
.task { await session.loadMe() }
}
}
-73
View File
@@ -1,73 +0,0 @@
import CoreTransferable
import SwiftUI
import UniformTypeIdentifiers
/// A client config as a .conf file for the share sheet.
nonisolated struct ConfFile: Transferable {
let name: String
let text: String
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(exportedContentType: .plainText) { file in
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(file.name).conf")
try file.text.write(to: url, atomically: true, encoding: .utf8)
return SentTransferredFile(url)
}
}
}
/// Shows a freshly issued config once: QR code, share, copy.
struct IssuedConfigContent: View {
let issued: IssuedConfig
@State private var copied = false
private var qrImage: UIImage? {
guard let qr = issued.qr, let comma = qr.firstIndex(of: ","),
let data = Data(base64Encoded: String(qr[qr.index(after: comma)...])) else { return nil }
return UIImage(data: data)
}
var body: some View {
ScrollView {
VStack(spacing: 16) {
Notice(text: "This is the only time the private key is shown. Scan or share it now: it is not stored on the server.")
if let img = qrImage {
Image(uiImage: img)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(maxWidth: 280)
.padding(12)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.accessibilityLabel("QR code of the client config for \(issued.peer.name)")
Text("Scan with the WireGuard app: + → Create from QR code.")
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
HStack(spacing: 12) {
ShareLink(item: ConfFile(name: issued.peer.name, text: issued.config),
preview: SharePreview("\(issued.peer.name).conf")) {
Label("Share .conf", systemImage: "square.and.arrow.up")
}
.buttonStyle(SecondaryButtonStyle())
Button {
UIPasteboard.general.string = issued.config
copied = true
} label: {
Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc")
}
.buttonStyle(SecondaryButtonStyle())
}
Text(issued.config)
.font(.mono(.caption))
.foregroundStyle(Color(hex: 0xE6E6E1))
.textSelection(.enabled)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(hex: 0x16171A), in: RoundedRectangle(cornerRadius: 10))
}
.padding(16)
}
.background(Color.gwGround)
}
}
-68
View File
@@ -1,68 +0,0 @@
import SwiftUI
/// The Kamon mark: a crest ring around the ghost, drawn from the same
/// 64×64 geometry as favicon.svg.
struct KamonMark: View {
var size: CGFloat = 40
var body: some View {
Canvas(renderer: Self.draw)
.frame(width: size, height: size)
.accessibilityHidden(true)
}
// nonisolated: SwiftUI may render a Canvas on its background render
// thread; a main-actor-bound renderer would crash there.
nonisolated private static func draw(_ ctx: inout GraphicsContext, _ canvas: CGSize) {
let s = canvas.width / 64
let tile = CGRect(x: 0, y: 0, width: 64 * s, height: 64 * s)
ctx.fill(Path(roundedRect: tile, cornerRadius: 14 * s), with: .color(.gwSumi))
ctx.stroke(Path(roundedRect: tile.insetBy(dx: 0.5 * s, dy: 0.5 * s), cornerRadius: 13.5 * s),
with: .color(.white.opacity(0.2)), lineWidth: s)
ctx.stroke(Path(ellipseIn: CGRect(x: 10 * s, y: 10 * s, width: 44 * s, height: 44 * s)),
with: .color(.white), lineWidth: 3.5 * s)
// The ghost is scaled by 0.66 around (32, 33), as in the SVG.
func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
CGPoint(x: (32 + (x - 32) * 0.66) * s, y: (33 + (y - 33) * 0.66) * s)
}
var ghost = Path()
ghost.move(to: p(18, 50))
ghost.addLine(to: p(18, 30))
ghost.addRelativeArc(center: p(32, 30), radius: 14 * 0.66 * s,
startAngle: .degrees(180), delta: .degrees(180))
ghost.addLine(to: p(46, 50))
for (x, y) in [(41.3, 46.0), (36.7, 50.0), (32.0, 46.0), (27.3, 50.0), (22.7, 46.0)] {
ghost.addLine(to: p(x, y))
}
ghost.closeSubpath()
ctx.fill(ghost, with: .color(.white))
let r = 3.2 * 0.66 * s
for c in [p(27, 30), p(37, 30)] {
ctx.fill(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(.gwShu))
}
}
}
/// Mark, wordmark and katakana reading, as in the web UI.
struct Lockup: View {
var size: CGFloat = 36
var body: some View {
HStack(spacing: size * 0.3) {
KamonMark(size: size)
VStack(alignment: .leading, spacing: 1) {
Text("GHOSTWIRE")
.font(.system(size: size * 0.47, weight: .semibold, design: .monospaced))
.tracking(size * 0.03)
Text("ゴーストワイヤー")
.font(.system(size: size * 0.27))
.tracking(size * 0.08)
.foregroundStyle(Color.gwText2)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("GHOSTWIRE")
}
}
-286
View File
@@ -1,286 +0,0 @@
import Foundation
// Types mirror the JSON of GHOSTWIRE's /api/v1. Traffic is from the peer's
// point of view: down is what the peer downloaded, up what it uploaded.
nonisolated struct Me: Decodable {
let name: String
let isAdmin: Bool
let scope: String
let version: String
}
nonisolated struct HealthCheck: Decodable, Hashable {
let name: String
let ok: Bool
let detail: String
}
nonisolated struct PeerCounts: Decodable {
let total, enabled, online, disabled, never: Int
}
nonisolated struct Traffic: Decodable {
let down: Int64
let up: Int64
}
nonisolated struct Status: Decodable {
let version: String
let interface: String
let listenPort: Int
let endpoint: String
let ipv4: String
let ipv6: String
let ipv6Enabled: Bool
let capacity: Int
let started: Date
let healthy: Bool
let checks: [HealthCheck]
let peers: PeerCounts
let traffic24h: Traffic
let traffic30d: Traffic
let topPeer30d: String
}
nonisolated struct StatPoint: Decodable, Identifiable, Hashable {
let t: Int64
let down: Int64
let up: Int64
var id: Int64 { t }
var date: Date { Date(timeIntervalSince1970: TimeInterval(t)) }
}
nonisolated struct StatsResponse: Decodable {
let range: String
let points: [StatPoint]
}
nonisolated struct PeerStats: Decodable, Hashable {
let online: Bool
let lastHandshake: Date?
let endpoint: String
let down24h, up24h, down30d, up30d, downTotal, upTotal: Int64
let location: GeoInfo?
}
/// Country and network of an address, from the server's DB-IP lookup.
nonisolated struct GeoInfo: Decodable, Hashable {
let country: String?
let countryName: String?
let asn: Int?
let network: String?
/// "Germany · Deutsche Telekom AG" or "Local network".
var label: String {
[countryName ?? country, network].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ")
}
}
/// One row of a peer's connection history.
nonisolated struct ConnSession: Decodable, Identifiable, Hashable {
let start: Date
let end: Date
let open: Bool
let seconds: Int64
let endpoint: String
let ip: String
let geo: GeoInfo?
let down: Int64
let up: Int64
var id: String { "\(start.timeIntervalSince1970)-\(ip)" }
}
nonisolated struct SessionsResponse: Decodable {
let sessions: [ConnSession]
}
nonisolated struct Peer: Decodable, Identifiable, Hashable {
let id: String
let name: String
let note: String
let enabled: Bool
let publicKey: String
let hasPresharedKey: Bool
let ipv4: String
let ipv6: String?
let dns: [String]? // nil = server default
let allowedIPs: [String]? // nil = server default
let keepalive: Int? // nil = server default
let effectiveDNS: [String]
let effectiveAllowedIPs: [String]
let effectiveKeepalive: Int
let created: Date
let configIssued: Date?
let setup: SetupInfo? // pending setup link, nil if none
let stats: PeerStats
}
/// A pending setup link as peer lists show it. The link itself is not in it.
nonisolated struct SetupInfo: Decodable, Hashable {
let expires: Date
let expired: Bool
let pinRequired: Bool
let pinFails: Int
}
nonisolated struct PeerList: Decodable {
let peers: [Peer]
let capacity: Int
let network: String
}
nonisolated struct PeerResult: Decodable {
let peer: Peer
let applyError: String
}
/// A freshly issued client config. The private key exists only here.
nonisolated struct IssuedConfig: Decodable, Identifiable {
let peer: Peer
let config: String
let qr: String?
let includesPrivateKey: Bool
let applyError: String
var id: String { peer.id + peer.publicKey }
}
/// A setup link for the admin to send. It sets up a device once.
nonisolated struct SetupSecret: Decodable, Hashable {
let url: String
let path: String
let pin: String?
let expires: Date
let qr: String
}
/// The answer to creating a peer or issuing a config with a setup link.
nonisolated struct LinkCreated: Decodable {
let peer: Peer
let setup: SetupSecret
let applyError: String
}
/// A config shown now, or a setup link to send.
enum IssueOutcome: Identifiable {
case config(IssuedConfig)
case link(LinkCreated)
var id: String {
switch self {
case .config(let c): c.id
case .link(let l): l.setup.path
}
}
var peer: Peer {
switch self {
case .config(let c): c.peer
case .link(let l): l.peer
}
}
var applyError: String {
switch self {
case .config(let c): c.applyError
case .link(let l): l.applyError
}
}
}
nonisolated struct PeerOnly: Decodable {
let peer: Peer
}
nonisolated struct ClientDefaults: Codable, Equatable {
var dns: [String]
var allowedIPs: [String]
var keepalive: Int
}
/// Server settings as GET/PATCH /server use them.
nonisolated struct ServerConfig: Codable, Equatable {
var interface: String
var publicKey: String
var keyCreated: Date
var listenPort: Int
var mtu: Int
var ipv4: String
var ipv6: String
var ipv6Enabled: Bool
var endpoint: String
var endpointPort: Int
var uplinkV4: String
var uplinkV6: String
var detectedUplinkV4: String
var detectedUplinkV6: String
var nat: Bool
var peerToPeer: Bool
var lanAccess: Bool
var openPort: Bool
var clientDefaults: ClientDefaults
var networks: [String] { ipv6Enabled ? [ipv4, ipv6] : [ipv4] }
}
nonisolated struct ServerResult: Decodable {
let server: ServerConfig
let applyError: String
let reissueNeeded: Bool?
}
nonisolated struct TLSSettings: Codable, Equatable {
var mode: String
var domain: String?
var email: String?
var staging: Bool?
var certFile: String?
var keyFile: String?
}
nonisolated struct WebSettings: Codable, Equatable {
var listen: String
var httpListen: String
var tls: TLSSettings
var sessionHours: Int
}
nonisolated struct LogSettings: Codable, Equatable {
var level: String
var maxSizeMB: Int
var maxFiles: Int
}
nonisolated struct StatsSettings: Codable, Equatable {
var hourlyHours: Int
var dailyDays: Int
var geoip: Bool?
}
nonisolated struct GeoStatus: Decodable {
let enabled: Bool
let updated: Date?
}
nonisolated struct AppSettings: Decodable {
var web: WebSettings
var log: LogSettings
var stats: StatsSettings
var adminUsername: String
var fingerprint: String
var logPath: String
var geo: GeoStatus?
}
nonisolated struct SettingsResult: Decodable {
let ok: Bool
let restartRequired: Bool
}
nonisolated struct ApplyResult: Decodable {
let applyError: String?
}
nonisolated struct DetectedIP: Decodable {
let ip: String
}
-179
View File
@@ -1,179 +0,0 @@
import SwiftUI
import VisionKit
/// First screen: pair with a server by scanning the QR code from the web
/// interface (Settings → Pair iOS app) or by entering the details.
struct PairingView: View {
@Environment(AppSession.self) private var session
@State private var scanning = false
@State private var manual = false
@State private var busy = false
@State private var error: String?
var body: some View {
VStack(spacing: 24) {
Spacer()
KamonMark(size: 96)
VStack(spacing: 6) {
Text("GHOSTWIRE").font(.system(size: 30, weight: .semibold, design: .monospaced)).tracking(1)
Text("ゴーストワイヤー").font(.footnote).tracking(4).foregroundStyle(Color.gwText2)
}
Text("In the web interface, open **Settings → Pair iOS app** and scan the QR code shown there.")
.multilineTextAlignment(.center)
.foregroundStyle(Color.gwText2)
.padding(.horizontal, 8)
Spacer()
if let error { Notice(text: error, isError: true) }
VStack(spacing: 12) {
Button {
if QRScanner.isAvailable { scanning = true } else { error = "The camera isn't available. Enter the details instead." }
} label: {
Label("Scan pairing QR code", systemImage: "qrcode.viewfinder")
}
.buttonStyle(PrimaryButtonStyle())
Button("Enter manually") { manual = true }
.buttonStyle(SecondaryButtonStyle())
}
.disabled(busy)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.gwGround)
.overlay { if busy { ProgressView().controlSize(.large) } }
.sheet(isPresented: $scanning) {
NavigationStack {
QRScanner { code in
scanning = false
Task { await pair(code) }
}
.ignoresSafeArea()
.navigationTitle("Scan pairing code")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { scanning = false } } }
}
}
.sheet(isPresented: $manual) { ManualPairingView() }
}
private func pair(_ code: String) async {
busy = true
defer { busy = false }
do {
try await session.pair(try Pairing.parse(code))
} catch {
self.error = error.localizedDescription
}
}
}
struct ManualPairingView: View {
@Environment(AppSession.self) private var session
@Environment(\.dismiss) private var dismiss
@State private var code = ""
@State private var url = "https://"
@State private var token = ""
@State private var fingerprint = ""
@State private var error: String?
@State private var busy = false
var body: some View {
NavigationStack {
Form {
Section {
TextField("Paste the pairing code", text: $code, axis: .vertical)
.font(.mono(.footnote))
.lineLimit(3...6)
} header: {
Text("Pairing code")
} footer: {
Text("In the web interface: Settings → Pair iOS app → Copy pairing code.")
}
Section {
TextField("https://vpn.example.net", text: $url)
.keyboardType(.URL)
TextField("wgt_…", text: $token)
.font(.mono(.footnote))
TextField("Fingerprint (self-signed certificates only)", text: $fingerprint)
.font(.mono(.footnote))
} header: {
Text("Or enter the details")
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if let error {
Section { Text(error).foregroundStyle(Color.gwErrInk) }
}
}
.groundBackground()
.navigationTitle("Pair manually")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Connect") { Task { await connect() } }.disabled(busy)
}
}
}
}
private func connect() async {
busy = true
defer { busy = false }
error = nil
do {
let p: Pairing
if !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
p = try Pairing.parse(code)
} else {
p = Pairing(url: url.trimmingCharacters(in: .whitespaces), token: token.trimmingCharacters(in: .whitespaces),
fingerprint: fingerprint.trimmingCharacters(in: .whitespaces))
guard p.token.hasPrefix("wgt_") else { throw APIError.badPairing("The token starts with wgt_.") }
}
try await session.pair(p)
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
/// Live QR scanning with VisionKit.
struct QRScanner: UIViewControllerRepresentable {
let onCode: (String) -> Void
static var isAvailable: Bool { DataScannerViewController.isSupported && DataScannerViewController.isAvailable }
func makeUIViewController(context: Context) -> DataScannerViewController {
let vc = DataScannerViewController(recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced,
recognizesMultipleItems: false,
isHighFrameRateTrackingEnabled: false,
isHighlightingEnabled: true)
vc.delegate = context.coordinator
DispatchQueue.main.async { try? vc.startScanning() }
return vc
}
func updateUIViewController(_ vc: DataScannerViewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) }
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onCode: (String) -> Void
private var done = false
init(onCode: @escaping (String) -> Void) { self.onCode = onCode }
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
guard !done else { return }
for item in addedItems {
if case .barcode(let code) = item, let text = code.payloadStringValue {
done = true
dataScanner.stopScanning()
onCode(text)
return
}
}
}
}
}
-316
View File
@@ -1,316 +0,0 @@
import SwiftUI
struct PeerDetailView: View {
let peerID: String
@Environment(AppSession.self) private var session
@Environment(\.dismiss) private var dismiss
@State private var peer: Peer?
@State private var server: ServerConfig?
@State private var range = "7d"
@State private var points: [StatPoint] = []
@State private var error: String?
@State private var issuing = false
@State private var issueWithLink = false
@State private var showingLink = false
@State private var confirmRevoke = false
@State private var copiedLink = false
@State private var confirmDelete = false
@State private var editing = false
@State private var sessions: [ConnSession] = []
@State private var allSessions = false
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 16) {
if let error { Notice(text: error, isError: true) }
if let p = peer {
header(p)
if let s = p.setup { setupLink(p, s) }
traffic
connection(p)
history.id("history")
clientConfig(p)
settings(p)
} else if error == nil {
ProgressView().padding(40)
}
}
.padding(16)
}
.background(Color.gwGround)
#if DEBUG
// Development: `-scrollToHistory YES` for screenshots of the history.
.task(id: sessions.count) {
if UserDefaults.standard.bool(forKey: "scrollToHistory"), !sessions.isEmpty { proxy.scrollTo("history", anchor: .top) }
// `-showSetupLink YES` opens the pending setup link.
if UserDefaults.standard.bool(forKey: "showSetupLink"), peer?.setup != nil { showingLink = true }
}
#endif
}
.navigationTitle(peer?.name ?? "Peer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let p = peer {
Menu {
Button { Task { await toggle(p) } } label: {
Label(p.enabled ? "Disable" : "Enable", systemImage: p.enabled ? "pause.circle" : "play.circle")
}
Button(role: .destructive) { confirmDelete = true } label: { Label("Delete", systemImage: "trash") }
} label: {
Label("Actions", systemImage: "ellipsis.circle")
}
}
}
.confirmationDialog("Delete \(peer?.name ?? "peer")?", isPresented: $confirmDelete, titleVisibility: .visible) {
Button("Delete peer", role: .destructive) { Task { await delete() } }
} message: {
Text("The device loses access immediately. Its traffic history is deleted too. This cannot be undone.")
}
.confirmationDialog("Revoke the setup link?", isPresented: $confirmRevoke, titleVisibility: .visible) {
Button("Revoke link", role: .destructive) { Task { await revoke() } }
} message: {
Text("The link stops working immediately.")
}
.sheet(isPresented: $issuing, onDismiss: { Task { await load() } }) {
if let p = peer { IssueSheet(peer: p, startWithLink: issueWithLink) }
}
.sheet(isPresented: $showingLink) {
if let p = peer { SetupLinkSheet(peer: p) }
}
.sheet(isPresented: $editing, onDismiss: { Task { await load() } }) {
if let p = peer, let s = server { PeerEditView(peer: p, server: s) }
}
.refreshable { await load() }
.task { await load() }
}
private func header(_ p: Peer) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(p.name).font(.title2.weight(.semibold))
StatusBadge(state: PeerState(p))
Text((p.note.isEmpty ? "" : p.note + " · ") + "created " + fmtDate(p.created))
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var traffic: some View {
VStack(alignment: .leading, spacing: 10) {
SectionTitle(text: "Traffic")
Picker("Range", selection: $range) {
Text("24 h").tag("24h")
Text("7 days").tag("7d")
Text("30 days").tag("30d")
}
.pickerStyle(.segmented)
.onChange(of: range) { Task { await loadStats() } }
TrafficTotals(points: points)
if !points.isEmpty { TrafficChart(points: points, range: range, mode: .pair) }
}
.card()
}
private func connection(_ p: Peer) -> some View {
VStack(alignment: .leading, spacing: 12) {
SectionTitle(text: "Connection")
KV(key: "Tunnel address", value: p.ipv4 + "/32" + (p.ipv6.map { "\n" + $0 + "/128" } ?? ""), mono: true)
KV(key: "Endpoint", value: p.stats.endpoint.isEmpty ? "–" : p.stats.endpoint, mono: true)
KV(key: "Location", value: p.stats.location?.label.isEmpty == false ? p.stats.location!.label : "–")
KV(key: "Latest handshake", value: ago(p.stats.lastHandshake))
KV(key: "Public key", value: p.publicKey.isEmpty ? "–" : p.publicKey, mono: true)
KV(key: "Preshared key", value: p.hasPresharedKey ? "Set" : "None")
KV(key: "All-time traffic", value: "Download \(fmtBytes(p.stats.downTotal)) · Upload \(fmtBytes(p.stats.upTotal))")
}
.card()
}
private var history: some View {
VStack(alignment: .leading, spacing: 0) {
SectionTitle(text: "Connection history")
Text("Newest first. A new row starts when the device changes networks.")
.font(.caption)
.foregroundStyle(Color.gwText2)
.padding(.bottom, 8)
if sessions.isEmpty {
Text("No connections recorded yet.").font(.footnote).foregroundStyle(Color.gwText2).padding(.vertical, 6)
}
let shown = allSessions ? sessions : Array(sessions.prefix(8))
ForEach(shown) { se in
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(se.start.formatted(.dateTime.day().month(.abbreviated).hour().minute()))
.font(.subheadline.weight(.medium))
if se.open {
HStack(spacing: 4) {
Circle().fill(Color.gwGood).frame(width: 6, height: 6)
Text("online")
}
.font(.caption.weight(.medium))
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.gwBadge, in: Capsule())
}
}
Text(se.geo?.label.isEmpty == false ? se.geo!.label : "Unknown location")
.font(.footnote)
.foregroundStyle(se.geo == nil ? Color.gwText2 : Color.gwText)
Text(se.ip + " · " + fmtDuration(se.seconds))
.font(.mono(.caption))
.foregroundStyle(Color.gwText2)
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 4) {
Text("↓ " + fmtBytes(se.down)).font(.footnote.monospacedDigit())
Text("↑ " + fmtBytes(se.up)).font(.footnote.monospacedDigit()).foregroundStyle(Color.gwText2)
}
}
.padding(.vertical, 8)
.accessibilityElement(children: .combine)
if se.id != shown.last?.id { Divider() }
}
if sessions.count > 8 {
Button(allSessions ? "Show fewer" : "Show all \(sessions.count)") { allSessions.toggle() }
.font(.footnote.weight(.medium))
.padding(.top, 8)
}
HStack(spacing: 4) {
Text("Country and network:")
Link("IP Geolocation by DB-IP", destination: URL(string: "https://db-ip.com")!).underline()
}
.font(.caption2)
.foregroundStyle(Color.gwText2)
.padding(.top, 10)
}
.card()
}
private func clientConfig(_ p: Peer) -> some View {
VStack(alignment: .leading, spacing: 12) {
SectionTitle(text: "Client configuration")
Text("This server doesn't keep the peer's private key. To set up a device again, issue a new config. The old one stops working.")
.font(.footnote)
.foregroundStyle(Color.gwText2)
Button { issueWithLink = false; issuing = true } label: {
Label(p.publicKey.isEmpty ? "Issue config…" : "Issue new config…", systemImage: "qrcode")
}
.buttonStyle(PrimaryButtonStyle())
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.")
.font(.caption)
.foregroundStyle(Color.gwText2)
}
.card()
}
private func settings(_ p: Peer) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
SectionTitle(text: "Settings")
Spacer()
Button("Edit") { editing = true }.disabled(server == nil)
}
KV(key: "AllowedIPs (client)", value: p.effectiveAllowedIPs.joined(separator: ", ") + (p.allowedIPs == nil ? " · server default" : ""), mono: true)
KV(key: "DNS", value: (p.effectiveDNS.isEmpty ? "none" : p.effectiveDNS.joined(separator: ", ")) + (p.dns == nil ? " · server default" : ""), mono: true)
KV(key: "Persistent keepalive", value: (p.effectiveKeepalive > 0 ? "\(p.effectiveKeepalive) s" : "off") + (p.keepalive == nil ? " · server default" : ""))
}
.card()
}
private func load() async {
guard let api = session.api else { return }
do {
async let p: Peer = api.get("/peers/\(peerID)")
async let s: ServerConfig = api.get("/server")
(peer, server) = try await (p, s)
error = nil
await loadStats()
if let r: SessionsResponse = try? await api.get("/peers/\(peerID)/sessions?limit=100") { sessions = r.sessions }
} catch {
self.error = session.message(for: error)
}
}
private func loadStats() async {
guard let api = session.api else { return }
if let r: StatsResponse = try? await api.get("/peers/\(peerID)/stats?range=\(range)") { points = r.points }
}
private func toggle(_ p: Peer) async {
guard let api = session.api else { return }
do {
let r: PeerResult = try await api.send("POST", "/peers/\(p.id)/" + (p.enabled ? "disable" : "enable"))
session.reportApply(r.applyError)
peer = r.peer
} catch {
session.alert = session.message(for: error)
}
}
private func delete() async {
guard let api = session.api else { return }
do {
let r: ApplyResult = try await api.send("DELETE", "/peers/\(peerID)")
session.reportApply(r.applyError)
dismiss()
} catch {
session.alert = session.message(for: error)
}
}
private func setupLink(_ p: Peer, _ s: SetupInfo) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
SectionTitle(text: "Setup link")
Spacer()
Text(s.expired ? "Expired" : "Not opened yet").font(.footnote).foregroundStyle(Color.gwText2)
}
KV(key: s.expired ? "Expired" : "Expires", value: fmtStamp(s.expires))
KV(key: "PIN", value: s.pinRequired ? "Required · \(s.pinFails) of 5 wrong tries" : "Not required")
if !p.publicKey.isEmpty {
KV(key: "Current config", value: "Keeps working until the link is opened")
}
if s.expired {
Button("New link…") { issueWithLink = true; issuing = true }
.buttonStyle(PrimaryButtonStyle())
Button("Remove") { Task { await revoke() } }
.buttonStyle(SecondaryButtonStyle())
} else {
Button { showingLink = true } label: {
Label(s.pinRequired ? "Share link & PIN" : "Share link", systemImage: "square.and.arrow.up")
}
.buttonStyle(PrimaryButtonStyle())
HStack(spacing: 12) {
Button { Task { await copyLink() } } label: {
Label(copiedLink ? "Copied" : "Copy link", systemImage: copiedLink ? "checkmark" : "doc.on.doc")
}
.buttonStyle(SecondaryButtonStyle())
Button("Revoke", role: .destructive) { confirmRevoke = true }
.buttonStyle(SecondaryButtonStyle(ink: .gwErrInk))
}
}
}
.card()
}
private func copyLink() async {
guard let api = session.api else { return }
do {
let s: SetupSecret = try await api.get("/peers/\(peerID)/setup")
UIPasteboard.general.string = s.url
copiedLink = true
} catch {
session.alert = session.message(for: error)
}
}
private func revoke() async {
guard let api = session.api else { return }
do {
let r: PeerOnly = try await api.send("DELETE", "/peers/\(peerID)/setup")
peer = r.peer
} catch {
session.alert = session.message(for: error)
}
}
}
-276
View File
@@ -1,276 +0,0 @@
import SwiftUI
/// The three per-peer overrides. Each is "server default" (sent as null) or
/// a value of its own.
struct PeerOverrides {
enum Route: String { case serverDefault, vpnOnly, custom }
enum Choice: String { case serverDefault, custom }
enum Keepalive: String { case serverDefault, off, custom }
var route: Route = .serverDefault
var routeText = ""
var dns: Choice = .serverDefault
var dnsText = ""
var keepalive: Keepalive = .serverDefault
var keepaliveText = ""
init() {}
init(peer p: Peer, server s: ServerConfig) {
if let a = p.allowedIPs {
route = a == s.networks ? .vpnOnly : .custom
routeText = a.joined(separator: ", ")
}
if let d = p.dns {
dns = .custom
dnsText = d.joined(separator: ", ")
}
if let k = p.keepalive {
keepalive = k == 0 ? .off : .custom
keepaliveText = k == 0 ? "" : String(k)
}
}
func body(server s: ServerConfig) throws -> [String: Any?] {
var out: [String: Any?] = [:]
switch route {
case .serverDefault: out["allowedIPs"] = nil as [String]?
case .vpnOnly: out["allowedIPs"] = s.networks
case .custom: out["allowedIPs"] = splitList(routeText)
}
out["dns"] = dns == .serverDefault ? nil as [String]? : splitList(dnsText)
switch keepalive {
case .serverDefault: out["keepalive"] = nil as Int?
case .off: out["keepalive"] = 0
case .custom:
guard let k = Int(keepaliveText), k >= 0 else { throw APIError.server("Keepalive must be a number of seconds.") }
out["keepalive"] = k
}
return out
}
}
/// Form sections for the overrides, shared by Add and Edit.
struct OverrideSections: View {
@Binding var o: PeerOverrides
let server: ServerConfig
var body: some View {
let d = server.clientDefaults
Section {
Picker("Route", selection: $o.route) {
Text("Server default · \(d.allowedIPs.joined(separator: ", "))").tag(PeerOverrides.Route.serverDefault)
Text("Only the VPN network").tag(PeerOverrides.Route.vpnOnly)
Text("Custom").tag(PeerOverrides.Route.custom)
}
.pickerStyle(.inline)
.labelsHidden()
if o.route == .custom {
TextField("10.0.0.0/24, 192.168.1.0/24", text: $o.routeText).font(.mono(.footnote))
}
} header: {
Text("Route through the VPN (AllowedIPs)")
}
Section("DNS") {
Picker("DNS", selection: $o.dns) {
Text("Server default · \(d.dns.isEmpty ? "none" : d.dns.joined(separator: ", "))").tag(PeerOverrides.Choice.serverDefault)
Text("Custom").tag(PeerOverrides.Choice.custom)
}
.pickerStyle(.inline)
.labelsHidden()
if o.dns == .custom {
TextField("9.9.9.9, 149.112.112.112", text: $o.dnsText).font(.mono(.footnote))
}
}
Section {
Picker("Keepalive", selection: $o.keepalive) {
Text("Server default · \(d.keepalive > 0 ? "\(d.keepalive) s" : "off")").tag(PeerOverrides.Keepalive.serverDefault)
Text("Off").tag(PeerOverrides.Keepalive.off)
Text("Custom").tag(PeerOverrides.Keepalive.custom)
}
if o.keepalive == .custom {
TextField("Seconds", text: $o.keepaliveText).keyboardType(.numberPad)
}
} header: {
Text("Persistent keepalive")
} footer: {
Text("Keeps the tunnel open behind NAT.")
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
}
struct AddPeerView: View {
@Environment(AppSession.self) private var session
@Environment(\.dismiss) private var dismiss
@State private var server: ServerConfig?
@State private var name = ""
@State private var note = ""
@State private var ipv4 = ""
@State private var overrides = PeerOverrides()
@State private var psk = true
@State private var handover = Handover()
@State private var error: String?
@State private var busy = false
@State private var issued: IssueOutcome?
var body: some View {
NavigationStack {
Group {
if let issued {
IssueOutcomeContent(outcome: issued)
} else if let server {
form(server)
} else {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
}
}
.navigationTitle(issued.map(outcomeTitle) ?? "Add peer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if issued == nil {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Create") { Task { await create() } }.disabled(busy || name.isEmpty || server == nil)
}
} else {
ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
}
}
.task {
guard let api = session.api else { return }
do { server = try await api.get("/server") } catch { self.error = session.message(for: error) }
}
}
.interactiveDismissDisabled(issued != nil)
}
private func form(_ server: ServerConfig) -> some View {
Form {
Section {
TextField("Name", text: $name)
TextField("Note (optional)", text: $note)
TextField("IPv4 address (next free if empty)", text: $ipv4)
.font(.mono(.body))
.keyboardType(.numbersAndPunctuation)
} footer: {
Text("Names: letters, numbers and . _ @ - · max 32 · unique. Network \(server.ipv4).")
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
OverrideSections(o: $overrides, server: server)
Section {
Toggle("Add a preshared key", isOn: $psk)
} header: {
Text("Keys")
} footer: {
Text("The private key is never stored on the server.")
}
HandoverSection(h: $handover)
if let error {
Section { Text(error).foregroundStyle(Color.gwErrInk) }
}
}
.groundBackground()
}
private func create() async {
guard let api = session.api, let server else { return }
busy = true
defer { busy = false }
error = nil
do {
var body = try overrides.body(server: server)
body["name"] = name.trimmingCharacters(in: .whitespaces)
body["note"] = note.trimmingCharacters(in: .whitespaces)
body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces)
body["presharedKey"] = psk
body.merge(handover.body) { _, new in new }
let r = try await api.issue("/peers", body)
session.reportApply(r.applyError)
issued = r
} catch {
self.error = session.message(for: error)
}
}
}
struct PeerEditView: View {
let peer: Peer
let server: ServerConfig
@Environment(AppSession.self) private var session
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var note = ""
@State private var ipv4 = ""
@State private var overrides = PeerOverrides()
@State private var error: String?
@State private var busy = false
var body: some View {
NavigationStack {
Form {
Section {
TextField("Name", text: $name)
TextField("Note", text: $note)
TextField("IPv4 address", text: $ipv4)
.font(.mono(.body))
.keyboardType(.numbersAndPunctuation)
} footer: {
Text("Name and address changes apply immediately. A new address needs a new client config.")
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
OverrideSections(o: $overrides, server: server)
Section {
Text("DNS, AllowedIPs and keepalive are part of the client config: they take effect after the config is issued again.")
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
if let error {
Section { Text(error).foregroundStyle(Color.gwErrInk) }
}
}
.groundBackground()
.navigationTitle("Edit \(peer.name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Save") { Task { await save() } }.disabled(busy)
}
}
.onAppear {
name = peer.name
note = peer.note
ipv4 = peer.ipv4
overrides = PeerOverrides(peer: peer, server: server)
}
}
}
private func save() async {
guard let api = session.api else { return }
busy = true
defer { busy = false }
error = nil
do {
var body = try overrides.body(server: server)
body["name"] = name
body["note"] = note
body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces)
let r: PeerResult = try await api.send("PATCH", "/peers/\(peer.id)", body)
session.reportApply(r.applyError)
dismiss()
} catch {
self.error = session.message(for: error)
}
}
}
-154
View File
@@ -1,154 +0,0 @@
import SwiftUI
struct PeerRow: View {
enum Period { case day, month }
let peer: Peer
let period: Period
var body: some View {
let down = period == .day ? peer.stats.down24h : peer.stats.down30d
let up = period == .day ? peer.stats.up24h : peer.stats.up30d
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(peer.name).font(.body.weight(.semibold)).foregroundStyle(Color.gwText)
if !peer.note.isEmpty {
Text(peer.note).font(.caption).foregroundStyle(Color.gwText2).lineLimit(1)
}
StatusBadge(state: PeerState(peer))
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 4) {
Text("↓ " + fmtBytes(down)).font(.footnote.monospacedDigit())
Text("↑ " + fmtBytes(up)).font(.footnote.monospacedDigit()).foregroundStyle(Color.gwText2)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Download \(fmtBytes(down)), upload \(fmtBytes(up))")
}
.padding(.vertical, 8)
.contentShape(Rectangle())
}
}
struct PeersView: View {
@Environment(AppSession.self) private var session
@State private var list: PeerList?
@State private var query = ""
@State private var filter = "all"
@State private var error: String?
@State private var adding = false
@State private var deleting: Peer?
@State private var path = NavigationPath()
private var filtered: [Peer] {
let q = query.lowercased()
return (list?.peers ?? []).filter { p in
let hit = q.isEmpty || "\(p.name) \(p.ipv4) \(p.note)".lowercased().contains(q)
return hit && (filter == "all" || PeerState(p).key == filter)
}
}
var body: some View {
NavigationStack(path: $path) {
List {
Section {
Picker("Status", selection: $filter) {
Text("All").tag("all")
Text("Online").tag("online")
Text("Offline").tag("offline")
Text("Disabled").tag("disabled")
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
if let error {
Section { Notice(text: error, isError: true) }
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
Section {
ForEach(filtered) { p in
NavigationLink(value: p.id) { PeerRow(peer: p, period: .month) }
.swipeActions(edge: .trailing) {
// The app-wide ink tint would override the destructive red.
Button(role: .destructive) { deleting = p } label: { Label("Delete", systemImage: "trash") }
.tint(Color.gwBad)
Button { Task { await toggle(p) } } label: {
Label(p.enabled ? "Disable" : "Enable", systemImage: p.enabled ? "pause.circle" : "play.circle")
}
.tint(.gray)
}
}
if list != nil && filtered.isEmpty {
Text(list?.peers.isEmpty == true ? "No peers yet. Tap + to add one." : "No peers match this filter.")
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
} footer: {
if let list {
Text("\(list.peers.count) of \(list.capacity) addresses in \(list.network) used. Traffic is for the last 30 days, from the peer's side.")
}
}
}
.groundBackground()
.searchable(text: $query, prompt: "Name, address or note")
.navigationTitle("Peers")
.toolbar {
Button { adding = true } label: { Label("Add peer", systemImage: "plus") }
}
.navigationDestination(for: String.self) { PeerDetailView(peerID: $0) }
.sheet(isPresented: $adding, onDismiss: { Task { await load() } }) { AddPeerView() }
.confirmationDialog("Delete peer?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),
titleVisibility: .visible, presenting: deleting) { p in
Button("Delete \(p.name)", role: .destructive) { Task { await delete(p) } }
} message: { _ in
Text("The device loses access immediately. Its traffic history is deleted too.")
}
.refreshable { await load() }
.task {
await load()
#if DEBUG
// Development: `-openFirstPeer YES` and `-addPeer YES`.
if UserDefaults.standard.bool(forKey: "openFirstPeer"), let first = list?.peers.first { path.append(first.id) }
if UserDefaults.standard.bool(forKey: "addPeer") { adding = true }
#endif
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
await load()
}
}
}
}
private func load() async {
guard let api = session.api else { return }
do {
list = try await api.get("/peers")
error = nil
} catch {
self.error = session.message(for: error)
}
}
private func toggle(_ p: Peer) async {
guard let api = session.api else { return }
do {
let r: PeerResult = try await api.send("POST", "/peers/\(p.id)/" + (p.enabled ? "disable" : "enable"))
session.reportApply(r.applyError)
await load()
} catch {
session.alert = session.message(for: error)
}
}
private func delete(_ p: Peer) async {
guard let api = session.api else { return }
do {
let r: ApplyResult = try await api.send("DELETE", "/peers/\(p.id)")
session.reportApply(r.applyError)
await load()
} catch {
session.alert = session.message(for: error)
}
}
}
-16
View File
@@ -1,16 +0,0 @@
<?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>
<!-- GHOSTWIRE talks only to the user's own server. It collects no data,
does no tracking and uses no required-reason APIs in release builds. -->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
-267
View File
@@ -1,267 +0,0 @@
import SwiftUI
struct ServerView: View {
@Environment(AppSession.self) private var session
@State private var original: ServerConfig?
@State private var draft: ServerConfig?
@State private var checks: [HealthCheck] = []
@State private var error: String?
@State private var busy = false
@State private var detected: String?
@State private var confirmRotate = false
/// Fields that can be changed, with the label shown in the apply bar.
private static let fields: [(String, String)] = [
("listenPort", "Listen port"), ("mtu", "MTU"), ("ipv4", "IPv4 network"), ("ipv6", "IPv6 network"),
("ipv6Enabled", "IPv6"), ("endpoint", "Endpoint host"), ("endpointPort", "Endpoint port"),
("uplinkV4", "IPv4 uplink"), ("uplinkV6", "IPv6 uplink"), ("nat", "NAT"), ("peerToPeer", "Peer-to-peer"),
("lanAccess", "LAN access"), ("openPort", "Open port"), ("clientDefaults", "Client defaults"),
]
private static let disruptive: Set<String> = ["listenPort", "ipv4", "ipv6", "ipv6Enabled"]
private static let quad9 = ["9.9.9.9", "149.112.112.112"]
private func dict(_ c: ServerConfig) -> [String: Any] {
guard let data = try? JSONEncoder().encode(c),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
return obj
}
private var changed: [String] {
guard let original, let draft else { return [] }
let a = dict(original), b = dict(draft)
return Self.fields.map(\.0).filter { k in
let x = try? JSONSerialization.data(withJSONObject: [a[k] ?? NSNull()], options: .sortedKeys)
let y = try? JSONSerialization.data(withJSONObject: [b[k] ?? NSNull()], options: .sortedKeys)
return x != y
}
}
var body: some View {
NavigationStack {
Group {
if draft != nil {
form
} else if let error {
ScrollView { Notice(text: error, isError: true).padding(16) }.background(Color.gwGround)
} else {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
}
}
.navigationTitle("Server")
.safeAreaInset(edge: .bottom) { applyBar }
.refreshable { await load() }
.task { await load() }
.confirmationDialog("Rotate the server key?", isPresented: $confirmRotate, titleVisibility: .visible) {
Button("Rotate key", role: .destructive) { Task { await rotate() } }
} message: {
Text("Every client config stops working until it is issued again. Use this only if the server key may have leaked.")
}
}
}
// Bindings into the draft; the form only shows once the draft exists.
private func bind<T>(_ kp: WritableKeyPath<ServerConfig, T>) -> Binding<T> {
Binding(get: { draft![keyPath: kp] }, set: { draft![keyPath: kp] = $0 })
}
private func listBind(_ kp: WritableKeyPath<ServerConfig, [String]>) -> Binding<String> {
Binding(get: { draft![keyPath: kp].joined(separator: ", ") }, set: { draft![keyPath: kp] = splitList($0) })
}
private var form: some View {
Form {
Section("Health") {
ForEach(checks, id: \.self) { c in
HStack(alignment: .firstTextBaseline, spacing: 10) {
Circle().fill(c.ok ? Color.gwGood : Color.gwBad).frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
Text(c.name).font(.subheadline.weight(.medium))
Text(c.detail).font(.caption).foregroundStyle(Color.gwText2)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel((c.ok ? "OK: " : "Problem: ") + c.name + ", " + c.detail)
}
}
Section {
LabeledContent("Interface", value: draft!.interface)
LabeledContent("Listen port") {
TextField("51820", value: bind(\.listenPort), format: .number.grouping(.never))
.keyboardType(.numberPad).multilineTextAlignment(.trailing)
}
LabeledContent("MTU") {
TextField("1420", value: bind(\.mtu), format: .number.grouping(.never))
.keyboardType(.numberPad).multilineTextAlignment(.trailing)
}
LabeledContent("IPv4 network") {
TextField("10.0.0.0/24", text: bind(\.ipv4)).font(.mono(.body)).multilineTextAlignment(.trailing)
}
LabeledContent("IPv6 network") {
TextField("fd00::/64", text: bind(\.ipv6)).font(.mono(.footnote)).multilineTextAlignment(.trailing)
}
Toggle("IPv6 in the tunnel", isOn: bind(\.ipv6Enabled))
} header: {
Text("Interface")
} footer: {
Text("Changing the port or the networks drops connected peers, and every device needs a new config.")
}
Section {
TextField("vpn.example.net", text: bind(\.endpoint)).font(.mono(.body))
Button("Detect public IP") { Task { await detect() } }
LabeledContent("Port seen by clients") {
TextField(String(draft!.listenPort), value: bind(\.endpointPort), format: .number.grouping(.never))
.keyboardType(.numberPad).multilineTextAlignment(.trailing)
}
} header: {
Text("Public endpoint")
} footer: {
Text(detected.map { "Detected public IP: \($0)" } ?? "Where clients connect. 0 for the port means the listen port.")
}
Section {
Picker("DNS provider", selection: Binding(
get: { draft!.clientDefaults.dns == Self.quad9 ? "quad9" : "custom" },
set: { if $0 == "quad9" { draft!.clientDefaults.dns = Self.quad9 } }
)) {
Text("Quad9").tag("quad9")
Text("Custom").tag("custom")
}
LabeledContent("DNS servers") {
TextField("9.9.9.9", text: listBind(\.clientDefaults.dns)).font(.mono(.footnote)).multilineTextAlignment(.trailing)
}
LabeledContent("AllowedIPs") {
TextField("0.0.0.0/0, ::/0", text: listBind(\.clientDefaults.allowedIPs)).font(.mono(.footnote)).multilineTextAlignment(.trailing)
}
LabeledContent("Keepalive (s)") {
TextField("0", value: bind(\.clientDefaults.keepalive), format: .number.grouping(.never))
.keyboardType(.numberPad).multilineTextAlignment(.trailing)
}
} header: {
Text("Client defaults")
} footer: {
Text("For new configs and peers set to \"Server default\". Existing devices pick up changes after their config is issued again.")
}
Section {
LabeledContent("IPv4 uplink") {
TextField("auto: \(draft!.detectedUplinkV4)", text: bind(\.uplinkV4)).font(.mono(.body)).multilineTextAlignment(.trailing)
}
LabeledContent("IPv6 uplink") {
TextField("auto: \(draft!.detectedUplinkV6)", text: bind(\.uplinkV6)).font(.mono(.body)).multilineTextAlignment(.trailing)
}
Toggle("NAT to the internet", isOn: bind(\.nat))
Toggle("Peers reach each other", isOn: bind(\.peerToPeer))
Toggle("Peers reach the server's LAN", isOn: bind(\.lanAccess))
Toggle("Accept UDP \(String(draft!.listenPort)) in the input chain", isOn: bind(\.openPort))
} header: {
Text("Routing & firewall")
} footer: {
Text("Rules live in their own nftables table. If you also run ufw or firewalld, allow the port there.")
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Section {
KV(key: "Public key", value: draft!.publicKey, mono: true)
LabeledContent("Created", value: fmtDate(draft!.keyCreated))
Button("Rotate server key…", role: .destructive) { confirmRotate = true }
} header: {
Text("Server key")
}
if let error {
Section { Text(error).foregroundStyle(Color.gwErrInk) }
}
}
.groundBackground()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
@ViewBuilder private var applyBar: some View {
let c = changed
if !c.isEmpty {
let labels = Dictionary(uniqueKeysWithValues: Self.fields)
VStack(alignment: .leading, spacing: 10) {
Text("\(c.count) unsaved change\(c.count > 1 ? "s" : ""): " + c.compactMap { labels[$0] }.joined(separator: ", "))
.font(.footnote)
if c.contains(where: Self.disruptive.contains) {
Text("Connected peers drop and need new configs.").font(.footnote.weight(.semibold))
}
HStack(spacing: 10) {
Button("Discard") { draft = original }
.buttonStyle(SecondaryButtonStyle())
Button("Apply") { Task { await apply(c) } }
.buttonStyle(PrimaryButtonStyle())
.disabled(busy)
}
}
.padding(14)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 12)
.padding(.bottom, 6)
}
}
private func load() async {
guard let api = session.api else { return }
do {
async let s: ServerConfig = api.get("/server")
async let st: Status = api.get("/status")
let (server, status) = try await (s, st)
original = server
draft = server
checks = status.checks
error = nil
} catch {
self.error = session.message(for: error)
}
}
private func apply(_ keys: [String]) async {
guard let api = session.api, let draft else { return }
busy = true
defer { busy = false }
let d = dict(draft)
var body: [String: Any?] = [:]
for k in keys { body[k] = d[k] }
do {
let r: ServerResult = try await api.send("PATCH", "/server", body)
original = r.server
self.draft = r.server
error = nil
session.reportApply(r.applyError)
if r.reissueNeeded == true && r.applyError.isEmpty {
session.alert = "Applied. Existing devices need a new config: the endpoint, port or addresses changed."
}
if let st: Status = try? await api.get("/status") { checks = st.checks }
} catch {
self.error = session.message(for: error)
}
}
private func detect() async {
guard let api = session.api else { return }
do {
let r: DetectedIP = try await api.get("/server/detect-ip")
detected = r.ip
if draft?.endpoint.isEmpty == true { draft?.endpoint = r.ip }
} catch {
session.alert = session.message(for: error)
}
}
private func rotate() async {
guard let api = session.api else { return }
do {
let r: ServerResult = try await api.send("POST", "/server/rotate-key")
original = r.server
draft = r.server
session.reportApply(r.applyError)
} catch {
session.alert = session.message(for: error)
}
}
}
-110
View File
@@ -1,110 +0,0 @@
import Foundation
import Observation
import Security
/// What the web interface's "Pair iOS app" QR code contains.
struct Pairing: Codable, Equatable {
var url: String
var token: String
var fingerprint: String
/// Parses the pairing JSON from the QR code or the "Copy pairing code" button.
static func parse(_ text: String) throws -> Pairing {
guard let p = try? JSONDecoder().decode(Pairing.self, from: Data(text.utf8)),
p.url.hasPrefix("https://") || p.url.hasPrefix("http://"),
p.token.hasPrefix("wgt_") else {
throw APIError.badPairing("This is not a GHOSTWIRE pairing code.")
}
return p
}
}
/// The pairing is stored in the keychain, readable only on this device.
enum Keychain {
private static let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "aero.redetzke.ghostwire",
kSecAttrAccount as String: "pairing",
]
static func save(_ p: Pairing) {
delete()
var q = base
q[kSecValueData as String] = try? JSONEncoder().encode(p)
q[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(q as CFDictionary, nil)
}
static func load() -> Pairing? {
var q = base
q[kSecReturnData as String] = true
q[kSecMatchLimit as String] = kSecMatchLimitOne
var out: CFTypeRef?
guard SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess, let data = out as? Data else { return nil }
return try? JSONDecoder().decode(Pairing.self, from: data)
}
static func delete() {
SecItemDelete(base as CFDictionary)
}
}
/// App-wide state: the paired server and messages shown as alerts.
@Observable
final class AppSession {
private(set) var pairing: Pairing?
private(set) var api: API?
var me: Me?
var alert: String?
init() {
#if DEBUG
// Development: `-pairing '<json>'` as a launch argument pairs the app.
// Read the raw arguments: UserDefaults would parse the JSON as a plist.
let args = ProcessInfo.processInfo.arguments
if let i = args.firstIndex(of: "-pairing"), i + 1 < args.count, let p = try? Pairing.parse(args[i + 1]) {
Keychain.save(p)
}
#endif
if let p = Keychain.load() {
pairing = p
api = API(pairing: p)
}
}
func pair(_ p: Pairing) async throws {
let api = API(pairing: p)
let me: Me = try await api.get("/auth/me")
Keychain.save(p)
self.pairing = p
self.api = api
self.me = me
}
func loadMe() async {
guard let api, me == nil else { return }
me = try? await api.get("/auth/me")
}
func disconnect() {
Keychain.delete()
pairing = nil
api = nil
me = nil
}
/// Turns an error into a message; a revoked token returns to pairing.
func message(for error: Error) -> String {
if case APIError.unauthorized = error {
disconnect()
}
return error.localizedDescription
}
/// Reports a kernel apply failure after a successful save.
func reportApply(_ applyError: String?) {
if let e = applyError, !e.isEmpty {
alert = "Saved, but applying to WireGuard failed: " + e
}
}
}
-313
View File
@@ -1,313 +0,0 @@
import SwiftUI
struct SettingsView: View {
@Environment(AppSession.self) private var session
@State private var settings: AppSettings?
@State private var web: WebSettings?
@State private var log: LogSettings?
@State private var stats: StatsSettings?
@State private var error: String?
@State private var busy = false
@State private var offerRestart = false
@State private var confirmRestart = false
@State private var confirmShrink = false
@State private var confirmDisconnect = false
private static let hourly: [(Int, String)] = [(24, "1 day"), (48, "2 days"), (168, "7 days"), (336, "14 days"), (744, "31 days")]
private static let daily: [(Int, String)] = [(30, "30 days"), (90, "90 days"), (180, "6 months"), (400, "13 months"),
(730, "2 years"), (1825, "5 years"), (3660, "10 years")]
var body: some View {
NavigationStack {
Form {
deviceSection
if let error {
Section { Text(error).foregroundStyle(Color.gwErrInk) }
}
if web != nil { webSection }
if log != nil, stats != nil { retentionSection }
if log != nil { logSection }
Section {
Button("Restart service…") { confirmRestart = true }
} footer: {
Text("Password, API tokens and backups are managed in the web interface.")
}
}
.groundBackground()
.navigationTitle("Settings")
.refreshable { await load() }
.task { await load() }
.alert("Restart to apply?", isPresented: $offerRestart) {
Button("Later", role: .cancel) {}
Button("Restart now") { Task { await restart() } }
} message: {
Text("The web interface uses the new settings after the service restarts. VPN connections stay up.")
}
.confirmationDialog("Restart the service?", isPresented: $confirmRestart, titleVisibility: .visible) {
Button("Restart") { Task { await restart() } }
} message: {
Text("The web interface and API are gone for a few seconds. VPN connections stay up.")
}
.confirmationDialog("Delete older data?", isPresented: $confirmShrink, titleVisibility: .visible) {
Button("Save and delete", role: .destructive) { Task { await saveRetention() } }
} message: {
Text("The new limits are lower: older log files and traffic history beyond them are deleted. This cannot be undone.")
}
.confirmationDialog("Disconnect this iPhone?", isPresented: $confirmDisconnect, titleVisibility: .visible) {
Button("Disconnect", role: .destructive) { session.disconnect() }
} message: {
Text("The token is removed from this iPhone. Revoke it under Settings → API tokens in the web interface too.")
}
}
}
private var deviceSection: some View {
Section {
HStack {
Lockup(size: 40)
Spacer()
}
.padding(.vertical, 4)
LabeledContent("Server", value: session.pairing?.url ?? "–")
if let me = session.me {
LabeledContent("Signed in as", value: "\(me.name) · \(me.scope == "ro" ? "read only" : "full access")")
LabeledContent("Server version", value: me.version)
}
if let fp = session.pairing?.fingerprint, !fp.isEmpty {
KV(key: "Pinned certificate (SHA-256)", value: fp, mono: true)
}
LabeledContent("App version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "–")
Button("Disconnect this iPhone…", role: .destructive) { confirmDisconnect = true }
} header: {
Text("This iPhone")
}
}
private var webSection: some View {
let w = Binding(get: { web! }, set: { web = $0 })
let opt = { (kp: WritableKeyPath<TLSSettings, String?>) in
Binding<String>(get: { web!.tls[keyPath: kp] ?? "" }, set: { web!.tls[keyPath: kp] = $0 })
}
return Section {
LabeledContent("Listen address") {
TextField(":443", text: w.listen).font(.mono(.body)).multilineTextAlignment(.trailing)
}
LabeledContent("HTTP listen address") {
TextField("off", text: w.httpListen).font(.mono(.body)).multilineTextAlignment(.trailing)
}
Picker("HTTPS", selection: w.tls.mode) {
Text("Let's Encrypt").tag("acme")
Text("Self-signed").tag("selfsigned")
Text("Certificate files").tag("files")
Text("Off (reverse proxy)").tag("off")
}
if web!.tls.mode == "acme" {
TextField("Domain", text: opt(\.domain)).font(.mono(.body))
TextField("Email for Let's Encrypt (optional)", text: opt(\.email)).keyboardType(.emailAddress)
Toggle("Use the staging CA", isOn: Binding(get: { web!.tls.staging ?? false }, set: { web!.tls.staging = $0 }))
}
if web!.tls.mode == "files" {
TextField("Certificate file", text: opt(\.certFile)).font(.mono(.footnote))
TextField("Key file", text: opt(\.keyFile)).font(.mono(.footnote))
}
Picker("Session length", selection: w.sessionHours) {
Text("1 hour").tag(1)
Text("12 hours").tag(12)
Text("1 day").tag(24)
Text("7 days").tag(168)
}
Button("Save web settings") { Task { await saveWeb() } }
.disabled(busy || web == settings?.web)
} header: {
Text("Web interface")
} footer: {
Text("Takes effect after the service restarts.")
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
private var retentionSection: some View {
let l = Binding(get: { log! }, set: { log = $0 })
let s = Binding(get: { stats! }, set: { stats = $0 })
return Section {
Stepper("Log file size: \(log!.maxSizeMB) MB", value: l.maxSizeMB, in: 1...1000)
Stepper("Old log files kept: \(log!.maxFiles)", value: l.maxFiles, in: 1...100)
Picker("Hourly traffic history", selection: s.hourlyHours) {
ForEach(options(Self.hourly, current: stats!.hourlyHours, unit: "hours"), id: \.0) { Text($0.1).tag($0.0) }
}
Picker("Daily traffic history", selection: s.dailyDays) {
ForEach(options(Self.daily, current: stats!.dailyDays, unit: "days"), id: \.0) { Text($0.1).tag($0.0) }
}
Toggle(isOn: Binding(get: { stats!.geoip ?? true }, set: { stats!.geoip = $0 })) {
VStack(alignment: .leading, spacing: 2) {
Text("Show country and network")
Text(geoStatusText).font(.caption).foregroundStyle(Color.gwText2)
}
}
Button("Save retention") {
guard let old = settings, let log, let stats else { return }
if log.maxFiles < old.log.maxFiles || stats.hourlyHours < old.stats.hourlyHours || stats.dailyDays < old.stats.dailyDays {
confirmShrink = true
} else {
Task { await saveRetention() }
}
}
.disabled(busy || (log == settings?.log && stats == settings?.stats))
} header: {
Text("Data retention")
} footer: {
Text("The log uses up to \(log!.maxSizeMB * (log!.maxFiles + 1)) MB on disk. Connection history is kept as long as the daily traffic history; all-time totals are always kept. Country and network come from the free DB-IP Lite databases, downloaded monthly and looked up on the server only. Applies immediately.")
}
}
private var logSection: some View {
Section {
Picker("Log level", selection: Binding(get: { log!.level }, set: { level in
log!.level = level
Task { await saveLevel(level) }
})) {
ForEach(["debug", "info", "warn", "error"], id: \.self) { Text($0).tag($0) }
}
NavigationLink("View log") { LogView() }
} header: {
Text("Log")
} footer: {
Text(settings?.logPath ?? "")
}
}
private var geoStatusText: String {
guard let updated = settings?.geo?.updated else { return "Database not downloaded yet" }
return "Database from \(fmtDate(updated))"
}
private func options(_ presets: [(Int, String)], current: Int, unit: String) -> [(Int, String)] {
presets.contains { $0.0 == current } ? presets : (presets + [(current, "\(current) \(unit)")]).sorted { $0.0 < $1.0 }
}
private func load() async {
guard let api = session.api else { return }
do {
let s: AppSettings = try await api.get("/settings")
settings = s
web = s.web
log = s.log
stats = s.stats
error = nil
} catch {
self.error = session.message(for: error)
}
}
private func patch(_ body: [String: Any?]) async throws -> SettingsResult {
guard let api = session.api else { throw APIError.unauthorized }
return try await api.send("PATCH", "/settings", body)
}
private func encoded<T: Encodable>(_ v: T) -> Any {
(try? JSONSerialization.jsonObject(with: JSONEncoder().encode(v))) ?? NSNull()
}
private func saveWeb() async {
guard let web else { return }
busy = true
defer { busy = false }
do {
let r = try await patch(["web": encoded(web)])
settings?.web = web
error = nil
if r.restartRequired { offerRestart = true }
} catch {
self.error = session.message(for: error)
}
}
private func saveRetention() async {
guard let log, let stats else { return }
busy = true
defer { busy = false }
do {
_ = try await patch(["log": encoded(log), "stats": encoded(stats)])
settings?.log = log
settings?.stats = stats
error = nil
} catch {
self.error = session.message(for: error)
}
}
private func saveLevel(_ level: String) async {
guard var l = settings?.log else { return }
l.level = level
do {
_ = try await patch(["log": encoded(l)])
settings?.log.level = level
} catch {
self.error = session.message(for: error)
}
}
private func restart() async {
guard let api = session.api else { return }
_ = try? await api.data("POST", "/restart")
session.alert = "Restarting. The app reconnects in a few seconds."
}
}
struct LogView: View {
@Environment(AppSession.self) private var session
@State private var level = "all"
@State private var lines: [String] = []
@State private var error: String?
var body: some View {
List {
Section {
Picker("Level", selection: $level) {
Text("All").tag("all")
Text("Info").tag("info")
Text("Warn").tag("warn")
Text("Error").tag("error")
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
if let error {
Section { Text(error).foregroundStyle(Color.gwErrInk) }
}
Section {
if lines.isEmpty && error == nil {
Text("No entries at this level.").foregroundStyle(Color.gwText2)
}
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.mono(.caption2))
.textSelection(.enabled)
}
} footer: {
Text("Newest first.")
}
}
.groundBackground()
.navigationTitle("Log")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: level) { Task { await load() } }
.refreshable { await load() }
.task { await load() }
}
private func load() async {
guard let api = session.api else { return }
do {
let data = try await api.data("GET", "/logs?limit=200&level=\(level)")
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let recs = obj?["lines"] as? [[String: Any]] ?? []
lines = recs.map(formatLogLine)
error = nil
} catch {
self.error = session.message(for: error)
}
}
}
-245
View File
@@ -1,245 +0,0 @@
import SwiftUI
/// "Show it here" or "Send a setup link", with the link's options. Used when
/// a peer is created and when its config is issued again.
struct Handover {
var link = false
var hours = 24
var pin = true
var body: [String: Any?] {
link ? ["delivery": "link", "linkHours": hours, "linkPIN": pin] : [:]
}
}
struct HandoverSection: View {
@Binding var h: Handover
var showHint = "QR code and .conf right after you tap Create. Best when the device is next to you."
var linkHint = "A one-time link you send to the device's owner. Keys are made when the link is opened and never stored."
var body: some View {
Section {
Picker("Hand over the config", selection: $h.link) {
Text("Show it here").tag(false)
Text("Send a setup link").tag(true)
}
.pickerStyle(.inline)
.labelsHidden()
if h.link {
Picker("Link valid for", selection: $h.hours) {
Text("1 hour").tag(1)
Text("24 hours").tag(24)
Text("7 days").tag(168)
}
Toggle("Require a PIN", isOn: $h.pin)
}
} header: {
Text("Hand over the config")
} footer: {
Text(h.link ? linkHint + (h.pin ? " Send the PIN by another channel than the link." : "") : showHint)
}
}
}
func qrImage(_ dataURL: String?) -> UIImage? {
guard let s = dataURL, let comma = s.firstIndex(of: ","),
let data = Data(base64Encoded: String(s[s.index(after: comma)...])) else { return nil }
return UIImage(data: data)
}
/// Shows a setup link to send: share, copy, PIN, QR code of the link.
struct SetupLinkContent: View {
let name: String
let setup: SetupSecret
@State private var copied: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Notice(text: setup.pin != nil
? "Anyone with this link and the PIN can set up this peer once. Send the PIN separately, e.g. by phone or another messenger."
: "Anyone with this link can set up this peer once. Send it only to the device's owner.")
VStack(alignment: .leading, spacing: 12) {
KV(key: "Link", value: setup.url, mono: true)
HStack(spacing: 12) {
if let url = URL(string: setup.url) {
ShareLink(item: url, subject: Text("VPN setup"), message: Text("Your VPN setup link for \(name)")) {
Label("Share link", systemImage: "square.and.arrow.up")
}
.buttonStyle(PrimaryButtonStyle())
}
copyButton("Copy", value: setup.url)
}
}
.card()
if let pin = setup.pin {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("PIN").font(.caption).foregroundStyle(Color.gwText2)
Text(pin).font(.mono(.title, weight: .semibold)).tracking(6).textSelection(.enabled)
}
Spacer()
copyButton("Copy PIN", value: pin).frame(maxWidth: 150)
}
.card()
}
VStack(alignment: .leading, spacing: 12) {
KV(key: "Valid until", value: fmtStamp(setup.expires))
KV(key: "Uses", value: "Once. Then the link stops working.")
}
.card()
if let img = qrImage(setup.qr) {
VStack(spacing: 8) {
Image(uiImage: img)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(maxWidth: 220)
.padding(12)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.accessibilityLabel("QR code of the setup link for \(name)")
Text("The QR code holds only the link, not the config.")
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
.frame(maxWidth: .infinity)
}
Text("Until the link is used, you can share it again or revoke it on the peer's page.")
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
.padding(16)
}
.background(Color.gwGround)
}
private func copyButton(_ title: String, value: String) -> some View {
Button {
UIPasteboard.general.string = value
copied = value
} label: {
Label(copied == value ? "Copied" : title, systemImage: copied == value ? "checkmark" : "doc.on.doc")
}
.buttonStyle(SecondaryButtonStyle())
}
}
/// The result of issuing: the config, or the setup link.
struct IssueOutcomeContent: View {
let outcome: IssueOutcome
var body: some View {
switch outcome {
case .config(let c): IssuedConfigContent(issued: c)
case .link(let l): SetupLinkContent(name: l.peer.name, setup: l.setup)
}
}
}
func outcomeTitle(_ o: IssueOutcome) -> String {
switch o {
case .config(let c): "Config for \(c.peer.name)"
case .link(let l): "Setup link for \(l.peer.name)"
}
}
/// Issue a config for an existing peer: choose how to hand it over, then
/// show the result in the same sheet.
struct IssueSheet: View {
let peer: Peer
var startWithLink = false
@Environment(AppSession.self) private var session
@Environment(\.dismiss) private var dismiss
@State private var h = Handover()
@State private var outcome: IssueOutcome?
@State private var busy = false
@State private var error: String?
var body: some View {
NavigationStack {
Group {
if let outcome {
IssueOutcomeContent(outcome: outcome)
} else {
Form {
Section {
if !peer.publicKey.isEmpty {
Text("New keys are created. The device that uses the current config stops working once it is replaced.")
}
if peer.setup != nil {
Text("This replaces the current setup link.")
}
}
.font(.footnote)
.foregroundStyle(Color.gwText2)
HandoverSection(h: $h,
showHint: "New keys now; QR code and .conf on this screen.",
linkHint: peer.publicKey.isEmpty ? "A one-time link you send to the device's owner."
: "The current config keeps working until the link is opened.")
if let error {
Section { Text(error).foregroundStyle(Color.gwErrInk) }
}
}
.groundBackground()
}
}
.navigationTitle(outcome.map(outcomeTitle) ?? (peer.publicKey.isEmpty ? "Issue config" : "Issue new config"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if outcome == nil {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Continue") { Task { await issue() } }.disabled(busy)
}
} else {
ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
}
}
}
.interactiveDismissDisabled(outcome != nil)
.onAppear { h.link = startWithLink }
}
private func issue() async {
guard let api = session.api else { return }
busy = true
defer { busy = false }
error = nil
do {
let o = try await api.issue("/peers/\(peer.id)/issue-config", h.link ? h.body : nil)
session.reportApply(o.applyError)
outcome = o
} catch {
self.error = session.message(for: error)
}
}
}
/// Fetches a pending setup link again and shows it.
struct SetupLinkSheet: View {
let peer: Peer
@Environment(AppSession.self) private var session
@Environment(\.dismiss) private var dismiss
@State private var setup: SetupSecret?
@State private var error: String?
var body: some View {
NavigationStack {
Group {
if let setup {
SetupLinkContent(name: peer.name, setup: setup)
} else if let error {
ScrollView { Notice(text: error, isError: true).padding(16) }.background(Color.gwGround)
} else {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
}
}
.navigationTitle("Setup link for \(peer.name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task {
guard let api = session.api else { return }
do { setup = try await api.get("/peers/\(peer.id)/setup") } catch { self.error = session.message(for: error) }
}
}
}
}
-205
View File
@@ -1,205 +0,0 @@
import SwiftUI
import UIKit
// Colours and components shared by all screens. They follow the web UI:
// ink, a light ground, white cards with hairlines, blue for downloads and
// orange for uploads. Dark mode uses the same roles, re-stepped.
nonisolated func uiColor(_ hex: UInt32) -> UIColor {
UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
}
// nonisolated: UIKit resolves dynamic colours on SwiftUI's render thread, so
// the provider closure must not be bound to the main actor (that crashes).
nonisolated extension Color {
init(hex: UInt32) { self.init(uiColor: uiColor(hex)) }
static func dynamic(_ light: UInt32, _ dark: UInt32) -> Color {
Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? uiColor(dark) : uiColor(light) })
}
static let gwGround = dynamic(0xF4F4F1, 0x0F0F10)
static let gwSurface = dynamic(0xFFFFFF, 0x1C1D21)
static let gwLine = dynamic(0xE3E3DE, 0x2C2D32)
static let gwText = dynamic(0x16171A, 0xF2F2EE)
static let gwText2 = dynamic(0x5B5C61, 0xA9AAA5)
static let gwAccent = dynamic(0x16171A, 0xF2F2EE)
static let gwBadge = dynamic(0xEFEFEB, 0x2A2B31)
static let gwWarnBg = dynamic(0xFDF0E1, 0x3A2A16)
static let gwWarnInk = dynamic(0x7A3D00, 0xF3C38B)
static let gwErrBg = dynamic(0xFBEFEE, 0x3A1C1C)
static let gwErrInk = dynamic(0xB4232A, 0xF2A7A3)
static let gwDown = Color(hex: 0x2A78D6)
static let gwUp = Color(hex: 0xEB6834)
static let gwGood = Color(hex: 0x0CA30C)
static let gwBad = Color(hex: 0xD03B3B)
static let gwSumi = Color(hex: 0x1B1B1D)
static let gwShu = Color(hex: 0xC8372D)
}
extension Font {
static func mono(_ style: Font.TextStyle = .body, weight: Font.Weight = .regular) -> Font {
.system(style, design: .monospaced).weight(weight)
}
}
struct CardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gwSurface, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.gwLine))
}
}
extension View {
func card() -> some View { modifier(CardModifier()) }
/// Forms and lists on the app's ground colour instead of system grey.
func groundBackground() -> some View {
scrollContentBackground(.hidden).background(Color.gwGround)
}
}
/// A full-width primary button in ink, like the web UI's primary buttons.
struct PrimaryButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var enabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.body.weight(.semibold))
.frame(maxWidth: .infinity, minHeight: 48)
.foregroundStyle(Color.gwGround)
.background(Color.gwAccent.opacity(configuration.isPressed ? 0.8 : 1), in: RoundedRectangle(cornerRadius: 10))
.opacity(enabled ? 1 : 0.5)
}
}
struct SecondaryButtonStyle: ButtonStyle {
var ink = Color.gwText
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.body.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 48)
.foregroundStyle(ink)
.background(Color.gwSurface.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gwLine))
}
}
// MARK: - Small components
struct Notice: View {
let text: String
var isError = false
var body: some View {
Text(text)
.font(.footnote)
.foregroundStyle(isError ? Color.gwErrInk : Color.gwWarnInk)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isError ? Color.gwErrBg : Color.gwWarnBg, in: RoundedRectangle(cornerRadius: 10))
}
}
enum PeerState {
case online(Date), offline(Date), never, disabled, waiting, noConfig
init(_ p: Peer) {
if !p.enabled { self = .disabled }
else if p.publicKey.isEmpty { self = p.setup.map { !$0.expired } == true ? .waiting : .noConfig }
else if let h = p.stats.lastHandshake { self = p.stats.online ? .online(h) : .offline(h) }
else { self = .never }
}
var label: String {
switch self {
case .online(let d): "Online · " + ago(d)
case .offline(let d): "Offline · " + ago(d)
case .never: "Never connected"
case .disabled: "Disabled"
case .waiting: "Waiting for setup"
case .noConfig: "No config yet"
}
}
var key: String {
switch self {
case .online: "online"
case .offline, .never, .waiting, .noConfig: "offline"
case .disabled: "disabled"
}
}
}
struct StatusDot: View {
let state: PeerState
var body: some View {
switch state {
case .online: Circle().fill(Color.gwGood).frame(width: 8, height: 8)
case .offline: Circle().fill(Color.gray).frame(width: 8, height: 8)
case .never, .noConfig: Circle().stroke(Color.gray, lineWidth: 1.5).frame(width: 8, height: 8)
case .waiting: Circle().fill(Color.gwUp).frame(width: 8, height: 8)
case .disabled: Circle().fill(Color.gwBad).frame(width: 8, height: 8)
}
}
}
struct StatusBadge: View {
let state: PeerState
var body: some View {
HStack(spacing: 6) {
StatusDot(state: state)
Text(state.label)
}
.font(.caption.weight(.medium))
.foregroundStyle(Color.gwText)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.gwBadge, in: Capsule())
}
}
struct Tile: View {
let title: String
let value: String
var suffix: String? = nil
var dot: Color? = nil
let sub: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.footnote).foregroundStyle(Color.gwText2)
HStack(alignment: .firstTextBaseline, spacing: 6) {
if let dot { Circle().fill(dot).frame(width: 10, height: 10) }
Text(value).font(.title2.weight(.semibold)).minimumScaleFactor(0.7).lineLimit(1)
if let suffix { Text(suffix).font(.body.weight(.medium)).foregroundStyle(Color.gwText2) }
}
Text(sub).font(.caption).foregroundStyle(Color.gwText2).lineLimit(2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.card()
}
}
/// A label/value row for read-only details.
struct KV: View {
let key: String
let value: String
var mono = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(key).font(.caption).foregroundStyle(Color.gwText2)
Text(value)
.font(mono ? .mono(.footnote) : .footnote)
.textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct SectionTitle: View {
let text: String
var body: some View { Text(text).font(.headline) }
}
-102
View File
@@ -1,102 +0,0 @@
import Charts
import SwiftUI
/// Traffic bars like the web UI: one blue bar per bucket (total), or a blue
/// download and orange upload bar side by side (pair). Touch a bar to see
/// its values in the line above the chart.
struct TrafficChart: View {
enum Mode { case total, pair }
let points: [StatPoint]
let range: String
let mode: Mode
@State private var selected: Date?
private var unit: Calendar.Component { range == "24h" ? .hour : .day }
private var selectedPoint: StatPoint? {
guard let selected else { return nil }
return points.first { Calendar.current.isDate($0.date, equalTo: selected, toGranularity: unit) }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
readout
.font(.footnote)
.foregroundStyle(Color.gwText2)
.frame(minHeight: 18, alignment: .leading)
Chart {
ForEach(points) { p in
if mode == .pair {
BarMark(x: .value("Time", p.date, unit: unit), y: .value("Bytes", Double(p.down)))
.foregroundStyle(by: .value("Series", "Download"))
.position(by: .value("Series", "Download"))
.cornerRadius(3)
BarMark(x: .value("Time", p.date, unit: unit), y: .value("Bytes", Double(p.up)))
.foregroundStyle(by: .value("Series", "Upload"))
.position(by: .value("Series", "Upload"))
.cornerRadius(3)
} else {
BarMark(x: .value("Time", p.date, unit: unit), y: .value("Bytes", Double(p.down + p.up)))
.foregroundStyle(by: .value("Series", "Download"))
.cornerRadius(3)
.opacity(selectedPoint == nil || selectedPoint == p ? 1 : 0.45)
}
}
}
.chartForegroundStyleScale(["Download": Color.gwDown, "Upload": Color.gwUp])
.chartLegend(.hidden)
.chartYAxis {
AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { v in
AxisGridLine()
AxisValueLabel {
if let b = v.as(Double.self) { Text(fmtBytes(Int64(b))).font(.caption2) }
}
}
}
.chartXAxis {
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
AxisValueLabel(format: range == "24h" ? .dateTime.hour() : .dateTime.day().month(.abbreviated))
}
}
.chartXSelection(value: $selected)
.frame(height: 180)
}
}
@ViewBuilder private var readout: some View {
if let p = selectedPoint {
let label = pointLabel(p, range: range)
if mode == .pair {
Text("\(label) · Download **\(fmtBytes(p.down))** · Upload **\(fmtBytes(p.up))**")
} else {
Text("**\(fmtBytes(p.down + p.up))** · \(label)")
}
} else if mode == .total, let peak = points.max(by: { $0.down + $0.up < $1.down + $1.up }), peak.down + peak.up > 0 {
Text("**\(fmtBytes(peak.down + peak.up))** · peak, \(pointLabel(peak, range: range))")
} else {
Text("Touch a bar to see its values.")
}
}
}
/// Legend with totals for the pair chart.
struct TrafficTotals: View {
let points: [StatPoint]
var body: some View {
let down = points.reduce(0) { $0 + $1.down }
let up = points.reduce(0) { $0 + $1.up }
HStack(spacing: 16) {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 3).fill(Color.gwDown).frame(width: 12, height: 12)
Text("Download **\(fmtBytes(down))**")
}
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 3).fill(Color.gwUp).frame(width: 12, height: 12)
Text("Upload **\(fmtBytes(up))**")
}
}
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
}
-37
View File
@@ -1,37 +0,0 @@
#!/bin/sh
# Archives GHOSTWIRE for the App Store and uploads it to App Store Connect.
#
# Needs Xcode signed in to your Apple developer account (Xcode → Settings →
# Accounts) and your team ID (developer.apple.com → Membership).
#
# TEAM_ID=ABCDE12345 ./release.sh archive and upload
# TEAM_ID=ABCDE12345 ./release.sh --export archive and export an .ipa only
#
# The build number is the current date and time, so every upload is unique.
set -eu
cd "$(dirname "$0")"
: "${TEAM_ID:?Set TEAM_ID to your Apple Developer team ID}"
BUILD=${BUILD:-$(date +%Y%m%d%H%M)}
DEST=upload
[ "${1:-}" = "--export" ] && DEST=export
OUT=build
rm -rf "$OUT"
mkdir -p "$OUT"
sed -e "s/__TEAM_ID__/$TEAM_ID/" -e "s/__DEST__/$DEST/" ExportOptions.plist > "$OUT/ExportOptions.plist"
xcodebuild -project GHOSTWIRE.xcodeproj -scheme GHOSTWIRE -configuration Release \
-destination 'generic/platform=iOS' -archivePath "$OUT/GHOSTWIRE.xcarchive" \
DEVELOPMENT_TEAM="$TEAM_ID" CURRENT_PROJECT_VERSION="$BUILD" \
-allowProvisioningUpdates archive
xcodebuild -exportArchive -archivePath "$OUT/GHOSTWIRE.xcarchive" \
-exportOptionsPlist "$OUT/ExportOptions.plist" -exportPath "$OUT" \
-allowProvisioningUpdates
if [ "$DEST" = upload ]; then
echo "Uploaded build $BUILD. It appears in App Store Connect → TestFlight after processing (usually 5–30 minutes)."
else
echo "Exported $OUT/GHOSTWIRE.ipa (build $BUILD)."
fi