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