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
@@ -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.
|
||||
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 181 KiB |
@@ -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>
|
||||
@@ -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 */;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||