feat: initial ui implementation

This commit is contained in:
Iain Learmonth 2026-04-13 14:18:42 +01:00
parent 1612ed099c
commit 13254d63c2
9 changed files with 709 additions and 31 deletions

22
LICENCE Normal file
View file

@ -0,0 +1,22 @@
Copyright 2021-2026 SR2 Communications Limited.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list
of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this
list of conditions and the following disclaimer in the documentation and/or other
materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.

22
README.md Normal file
View file

@ -0,0 +1,22 @@
cloud-dns-ios
=============
[![Translation status](https://hosted.weblate.org/widget/sr2/cloud-dns-ios/svg-badge.svg)](https://hosted.weblate.org/engage/sr2/)
[![License](https://img.shields.io/badge/License-BSD_2--Clause-orange.svg)](https://opensource.org/licenses/BSD-2-Clause)
iOS app to manage SR2® Cloud DNS configuration
Translations
------------
Snapshot templates support localisation.
Translations of strings in the template are managed on Weblate.
<a href="https://hosted.weblate.org/engage/sr2/">
<img src="https://hosted.weblate.org/widget/sr2/cloud-dns-ios/multi-auto.svg" alt="Translation status" />
</a>
Licence & Copyright
-------------------
&copy; SR2 Communications Limited. See [LICENCE](./LICENCE) for details of the BSD 2 clause licence.

View file

@ -184,6 +184,7 @@
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
name = Debug; name = Debug;
@ -240,6 +241,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
@ -249,8 +251,10 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@ -265,6 +269,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = uk.sr2.dns; PRODUCT_BUNDLE_IDENTIFIER = uk.sr2.dns;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
@ -280,8 +285,10 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@ -296,6 +303,7 @@
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = uk.sr2.dns; PRODUCT_BUNDLE_IDENTIFIER = uk.sr2.dns;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;

71
dns/BlockedCount.swift Normal file
View file

@ -0,0 +1,71 @@
import SwiftUI
import Foundation
struct BlockedCount: View {
@State private var txtRecord: String = "..."
var body: some View {
Text(txtRecord)
.onAppear {
fetchTXTRecord()
}
}
func parseResponse(data: Data) -> String? {
// This is a DNS wire format response and we make a lot of assumptions
// It is not critical functionality so just let it fail if it fails
guard data.count > 44 else { return nil }
let startIndex = 44
let subData = data.suffix(from: startIndex)
// Find the first space character (ASCII 32)
var endIndex: Int?
for (offset, byte) in subData.enumerated() {
if byte == 32 { // Space character
endIndex = offset
break
}
}
guard let foundEndIndex = endIndex else { return nil }
// Extract bytes between start and space
let numberData = subData.prefix(foundEndIndex)
return String(decoding: numberData, as: UTF8.self)
}
func fetchTXTRecord() {
let dohURL = URL(string: "https://dns.sr2.uk/dns-query?dns=DoQBAAABAAAAAAAABXN0YXRzB2ludmFsaWQAABAAAQ")!
let request = URLRequest(url: dohURL)
URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if error != nil {
txtRecord = "Error"
return
}
guard let data = data else {
txtRecord = "Error"
return
}
guard let count = parseResponse(data: data) else {
txtRecord = "Error"
return
}
txtRecord = count
}
}.resume()
}
}
#Preview {
BlockedCount()
}

41
dns/BlocklistRow.swift Normal file
View file

@ -0,0 +1,41 @@
import SwiftUI
struct BlocklistRow: View {
let option: BlocklistOption
let isSelected: Bool
var body: some View {
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? Color.green.opacity(0.15) : Color.gray.opacity(0.1))
.frame(width: 44, height: 44)
Image(systemName: option.icon)
.font(.title3)
.foregroundStyle(isSelected ? .green : .secondary)
}
VStack(alignment: .leading, spacing: 4) {
Text(option.id + " " + (option.enabled ? "" : "(Coming Soon)"))
.font(.body)
.fontWeight(isSelected ? .semibold : .regular)
Text(option.description)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.title3)
.transition(.scale.combined(with: .opacity))
}
}
.padding(.vertical, 8)
}
}

View file

@ -1,24 +0,0 @@
//
// ContentView.swift
// dns
//
// Created by irl on 12/04/2026.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}

281
dns/HomeView.swift Normal file
View file

@ -0,0 +1,281 @@
import SwiftUI
enum BlocklistOption: String, CaseIterable, Identifiable {
case secure = "Secure"
case securePlusAdblock = "Secure + Adblock"
var id: String { rawValue }
var enabled: Bool {
switch self {
case .secure:
return true
case .securePlusAdblock:
return false
}
}
var description: String {
switch self {
case .secure:
return "Malware and phishing protection"
case .securePlusAdblock:
return "Security plus ad and tracker blocking"
}
}
var icon: String {
switch self {
case .secure:
return "shield"
case .securePlusAdblock:
return "shield.righthalf.filled"
}
}
var server: String {
switch self {
case .secure:
return "dns.sr2.uk"
case .securePlusAdblock:
return "dnsplus.sr2.uk"
}
}
var ipv4: String {
switch self {
case .secure:
return "144.76.160.194"
case .securePlusAdblock:
return "192.0.2.1"
}
}
var ipv6: String {
switch self {
case .secure:
return "2a01:4f8:2210:23ea::4"
case .securePlusAdblock:
return "2001:db8::1"
}
}
}
enum ServiceStatus {
case pending
case operational
case degraded
case outage
var description: String {
switch self {
case .pending:
return "Fetching service status"
case .operational:
return "No issues detected"
case .degraded:
return "Performance degraded"
case .outage:
return "Service disruption"
}
}
var color: Color {
switch self {
case .pending:
return .gray
case .operational:
return .green
case .degraded:
return .orange
case .outage:
return .red
}
}
}
struct HomeView: View {
@State private var isEnabled = false
@State private var selectedBlocklist: BlocklistOption = .secure
@State private var serviceStatus: ServiceStatus = .operational
private let falsePositiveURL = URL(string: "https://www.sr2.uk/contact")!
private let statusURL = URL(string:
"https://status.sr2.uk/")!
private let tosURL = URL(string: "https://www.sr2.uk/terms")!
private let privacyPolicyURL = URL(string: "https://www.sr2.uk/privacy")!
var body: some View {
NavigationStack {
List {
// Main toggle section
Section {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("DNS Protection")
.font(.headline)
Text(isEnabled ? "Active" : "Inactive")
.font(.caption)
.foregroundStyle(isEnabled ? .green : .secondary)
}
Spacer()
Toggle("", isOn: $isEnabled)
.labelsHidden()
.tint(.green)
}
.padding(.vertical, 4)
}
// Blocklist selection
Section {
ForEach(BlocklistOption.allCases) { option in
BlocklistRow(
option: option,
isSelected: selectedBlocklist == option
)
.contentShape(Rectangle())
.onTapGesture {
guard option.enabled else { return }
withAnimation(.spring(duration: 0.3)) {
selectedBlocklist = option
}
}
.opacity(option.enabled ? 1 : 0.6)
}
} header: {
Text("Blocklist")
} footer: {
Text("Select the level of protection for your DNS queries")
}
// Status section
if isEnabled {
Section {
HStack {
Label("Status", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Spacer()
Text("Connected")
.foregroundStyle(.secondary)
}
HStack {
Label("Server", systemImage: "server.rack")
Spacer()
Text(selectedBlocklist.server)
.foregroundStyle(.secondary)
.font(.system(.body, design: .monospaced))
}
HStack {
Label("IPv4", systemImage: "globe")
Spacer()
Text(selectedBlocklist.ipv4)
.foregroundStyle(.secondary)
.font(.system(.body, design: .monospaced))
}
HStack {
Label("IPv6", systemImage: "globe")
Spacer()
Text(selectedBlocklist.ipv6)
.foregroundStyle(.secondary)
.font(.system(.body, design: .monospaced))
}
HStack {
Label("Domains in blocklist", systemImage: "xmark.shield.fill")
Spacer()
BlockedCount()
.foregroundStyle(.secondary)
}
} header: {
Text("Connection Details")
}
}
// Support section
Section {
Link(destination: falsePositiveURL) {
HStack {
Label {
Text("Report False Positive")
} icon: {
Image(systemName: "exclamationmark.bubble")
.foregroundStyle(.orange)
}
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundStyle(.secondary)
}
}.foregroundStyle(.primary)
} footer: {
Text("Submit incorrectly blocked domains for review")
}
// Service status section
Section {
Link(destination: statusURL) {
HStack(spacing: 12) {
Circle()
.fill(serviceStatus.color)
.frame(width: 12, height: 12)
VStack(alignment: .leading, spacing: 4) {
Text("Service Status")
.font(.headline)
Text(serviceStatus.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}.foregroundStyle(.primary)
Link(destination: tosURL) {
HStack(spacing: 12) {
Image(systemName: "doc.text")
Text("Terms of Service")
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundStyle(.secondary)
}
}.foregroundStyle(.primary)
Link(destination: privacyPolicyURL) {
HStack(spacing: 12) {
Image(systemName: "doc.text")
Text("Privacy Policy")
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundStyle(.secondary)
}
}.foregroundStyle(.primary)
}
}
.navigationTitle("SR2® Cloud DNS")
.animation(.default, value: isEnabled)
}
}
}
#Preview {
HomeView()
}

263
dns/Localizable.xcstrings Normal file
View file

@ -0,0 +1,263 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"(Coming Soon)" : {
"comment" : "Indicates that this feature is not yet implemented but will be soon",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "(Coming Soon)"
}
}
}
},
"Active" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Active"
}
}
}
},
"Blocklist" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Blocklist"
}
}
}
},
"Connected" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connected"
}
}
}
},
"Connection Details" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Connection Details"
}
}
}
},
"DNS Protection" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "DNS Protection"
}
}
}
},
"Domains in blocklist" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Domains in blocklist"
}
}
}
},
"Inactive" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Inactive"
}
}
}
},
"IPv4" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "IPv4"
}
}
}
},
"IPv6" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "IPv6"
}
}
}
},
"Malware and phishing protection" : {
"comment" : "Description of the blocklist contents",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Malware and phishing protection"
}
}
}
},
"No issues detected" : {
"comment" : "No current issues detected with the service",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No issues detected"
}
}
}
},
"Privacy Policy" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Privacy Policy"
}
}
}
},
"Report False Positive" : {
"comment" : "Link to report that a domain name has been incorrectly blocked",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Report False Positive"
}
}
}
},
"Secure" : {
"comment" : "Name of the blocklist that only includes malware and security threats",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Secure"
}
}
}
},
"Secure + Adblock" : {
"comment" : "Name of the blocklist that contains “Secure” plus ad blocking",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Secure + Adblock"
}
}
}
},
"Security plus ad and tracker blocking" : {
"comment" : "Description of the blocklist contents",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Security plus ad and tracker blocking"
}
}
}
},
"Select the level of protection for your DNS queries" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Select the level of protection for your DNS queries"
}
}
}
},
"Server" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Server"
}
}
}
},
"Service Status" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Service Status"
}
}
}
},
"SR2® Cloud DNS" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "SR2® Cloud DNS"
}
}
}
},
"Status" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Status"
}
}
}
},
"Submit incorrectly blocked domains for review" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Submit incorrectly blocked domains for review"
}
}
}
},
"Terms of Service" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Terms of Service"
}
}
}
}
},
"version" : "1.2"
}

View file

@ -1,9 +1,3 @@
//
// dnsApp.swift
// dns
//
// Created by irl on 12/04/2026.
//
import SwiftUI import SwiftUI
@ -11,7 +5,7 @@ import SwiftUI
struct dnsApp: App { struct dnsApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() HomeView()
} }
} }
} }