diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..12cc60a --- /dev/null +++ b/LICENCE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d93ef2b --- /dev/null +++ b/README.md @@ -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. + + +Translation status + + +Licence & Copyright +------------------- + +© SR2 Communications Limited. See [LICENCE](./LICENCE) for details of the BSD 2 clause licence. diff --git a/dns.xcodeproj/project.pbxproj b/dns.xcodeproj/project.pbxproj index a403640..94d3daf 100644 --- a/dns.xcodeproj/project.pbxproj +++ b/dns.xcodeproj/project.pbxproj @@ -184,6 +184,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -240,6 +241,7 @@ MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; VALIDATE_PRODUCT = YES; }; name = Release; @@ -249,8 +251,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -265,6 +269,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = uk.sr2.dns; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; @@ -280,8 +285,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -296,6 +303,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = uk.sr2.dns; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; diff --git a/dns/BlockedCount.swift b/dns/BlockedCount.swift new file mode 100644 index 0000000..8a0faab --- /dev/null +++ b/dns/BlockedCount.swift @@ -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() +} diff --git a/dns/BlocklistRow.swift b/dns/BlocklistRow.swift new file mode 100644 index 0000000..8f0b64d --- /dev/null +++ b/dns/BlocklistRow.swift @@ -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) + } +} diff --git a/dns/ContentView.swift b/dns/ContentView.swift deleted file mode 100644 index 29b6a4f..0000000 --- a/dns/ContentView.swift +++ /dev/null @@ -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() -} diff --git a/dns/HomeView.swift b/dns/HomeView.swift new file mode 100644 index 0000000..0bdd24a --- /dev/null +++ b/dns/HomeView.swift @@ -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() +} diff --git a/dns/Localizable.xcstrings b/dns/Localizable.xcstrings new file mode 100644 index 0000000..aeb6242 --- /dev/null +++ b/dns/Localizable.xcstrings @@ -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" +} \ No newline at end of file diff --git a/dns/dnsApp.swift b/dns/dnsApp.swift index 1011ed3..165bec1 100644 --- a/dns/dnsApp.swift +++ b/dns/dnsApp.swift @@ -1,9 +1,3 @@ -// -// dnsApp.swift -// dns -// -// Created by irl on 12/04/2026. -// import SwiftUI @@ -11,7 +5,7 @@ import SwiftUI struct dnsApp: App { var body: some Scene { WindowGroup { - ContentView() + HomeView() } } }