Add native iOS app with App Store preparation

iOS app (ios/, SwiftUI, iOS 17+) in the web UI's styling with the Kamon
logo. It covers everything the web interface does except password, API
tokens and backups: dashboard, peers with search and filter, peer detail
with traffic charts, add/edit peers, one-time config with QR code and
share sheet, server settings with apply bar, app settings, data
retention and log viewer.

- Pairing by QR code or pasted pairing code; token kept in the keychain;
  self-signed certificates are pinned by SHA-256 fingerprint.
- Colour providers and logo drawing are nonisolated: SwiftUI's background
  renderer calls them, and main-actor closures crashed there when the
  camera scanner was open.
- App Store: version 1.0, export compliance, privacy manifest, app icon,
  release.sh (archive and upload), listing text, review notes and 6.9"
  screenshots in ios/AppStore.

Server:
- Full-access API tokens may use settings, logs and restart; password,
  tokens, backup/restore and the admin username stay admin-only.
- Web pairing dialog gains "Copy pairing code".
- The development simulator reports health checks in Linux wording.

Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
This commit is contained in:
Daniel Redetzke
2026-10-03 18:34:41 +03:00
parent 1543069089
commit f31bb360c9
36 changed files with 3004 additions and 14 deletions
+90
View File
@@ -0,0 +1,90 @@
# 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.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

+18
View File
@@ -0,0 +1,18 @@
<?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
@@ -0,0 +1,256 @@
// !$*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 */;
}
+100
View File
@@ -0,0 +1,100 @@
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))
}
}
@@ -0,0 +1,23 @@
{
"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.

After

Width:  |  Height:  |  Size: 39 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+106
View File
@@ -0,0 +1,106 @@
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)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
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)
}
/// 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
@@ -0,0 +1,65 @@
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() }
}
}
+91
View File
@@ -0,0 +1,91 @@
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: issued.includesPrivateKey
? "This is the only time the private key is shown. Scan or share it now: it is not stored on the server."
: "The device keeps its own private key. Put it into the PrivateKey line.")
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)
}
}
/// IssuedConfigContent in its own sheet, for re-issued configs.
struct IssuedConfigView: View {
let issued: IssuedConfig
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
IssuedConfigContent(issued: issued)
.navigationTitle("Config for \(issued.peer.name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
}
.interactiveDismissDisabled()
}
}
+68
View File
@@ -0,0 +1,68 @@
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")
}
}
+191
View File
@@ -0,0 +1,191 @@
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
}
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 stats: PeerStats
}
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 }
}
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
}
nonisolated struct AppSettings: Decodable {
var web: WebSettings
var log: LogSettings
var stats: StatsSettings
var adminUsername: String
var fingerprint: String
var logPath: String
}
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
@@ -0,0 +1,179 @@
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
}
}
}
}
}
+198
View File
@@ -0,0 +1,198 @@
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 issued: IssuedConfig?
@State private var confirmIssue = false
@State private var confirmDelete = false
@State private var askKey = false
@State private var deviceKey = ""
@State private var editing = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
if let error { Notice(text: error, isError: true) }
if let p = peer {
header(p)
traffic
connection(p)
clientConfig(p)
settings(p)
} else if error == nil {
ProgressView().padding(40)
}
}
.padding(16)
}
.background(Color.gwGround)
.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("Issue a new config?", isPresented: $confirmIssue, titleVisibility: .visible) {
Button("Issue new config") { Task { await issue(publicKey: nil) } }
} message: {
Text("New keys are created. The device that uses the current config stops working until it gets the new one.")
}
.alert("Use a key from the device", isPresented: $askKey) {
TextField("Public key", text: $deviceKey)
.font(.mono(.footnote))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Cancel", role: .cancel) {}
Button("Replace key") { Task { await issue(publicKey: deviceKey) } }
} message: {
Text("Paste the public key the device generated. The current config stops working.")
}
.sheet(item: $issued, onDismiss: { Task { await load() } }) { IssuedConfigView(issued: $0) }
.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: "Latest handshake", value: ago(p.stats.lastHandshake))
KV(key: "Public key", value: 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 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 { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") }
.buttonStyle(PrimaryButtonStyle())
Button("Use a key from the device…") { deviceKey = ""; askKey = true }
.buttonStyle(SecondaryButtonStyle())
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "Created with a key from the device.")
.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()
} 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 issue(publicKey: String?) async {
guard let api = session.api else { return }
do {
let body: [String: Any?]? = publicKey.map { ["publicKey": $0.trimmingCharacters(in: .whitespacesAndNewlines)] }
issued = try await api.send("POST", "/peers/\(peerID)/issue-config", body)
} catch {
session.alert = session.message(for: error)
}
}
}
+287
View File
@@ -0,0 +1,287 @@
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 pasteKey = false
@State private var publicKey = ""
@State private var psk = true
@State private var error: String?
@State private var busy = false
@State private var issued: IssuedConfig?
var body: some View {
NavigationStack {
Group {
if let issued {
IssuedConfigContent(issued: issued)
} else if let server {
form(server)
} else {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
}
}
.navigationTitle(issued == nil ? "Add peer" : "Config for \(issued?.peer.name ?? "")")
.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 {
Picker("Keys", selection: $pasteKey) {
Text("Generate here").tag(false)
Text("Paste the client's public key").tag(true)
}
.pickerStyle(.inline)
.labelsHidden()
if pasteKey {
TextField("Public key", text: $publicKey)
.font(.mono(.footnote))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Toggle("Add a preshared key", isOn: $psk)
} header: {
Text("Keys")
} footer: {
Text(pasteKey ? "For clients that make their own keys." : "The private key appears once in the config and QR code. It isn't stored.")
}
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
if pasteKey { body["publicKey"] = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) }
let r: IssuedConfig = try await api.send("POST", "/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)
}
}
}
+152
View File
@@ -0,0 +1,152 @@
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) {
Button(role: .destructive) { deleting = p } label: { Label("Delete", systemImage: "trash") }
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
@@ -0,0 +1,16 @@
<?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
@@ -0,0 +1,267 @@
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
@@ -0,0 +1,110 @@
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
}
}
}
+302
View File
@@ -0,0 +1,302 @@
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) }
}
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. All-time traffic totals are always kept. 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 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)
}
}
}
+200
View File
@@ -0,0 +1,200 @@
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 {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.body.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 48)
.foregroundStyle(Color.gwText)
.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
init(_ p: Peer) {
if !p.enabled { self = .disabled }
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"
}
}
var key: String {
switch self {
case .online: "online"
case .offline, .never: "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: Circle().stroke(Color.gray, lineWidth: 1.5).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
@@ -0,0 +1,102 @@
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)
}
}
Executable
+37
View File
@@ -0,0 +1,37 @@
#!/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