1
0
Fork 0
This commit is contained in:
Atridad Lahiji 2025-03-19 01:25:06 -06:00
parent be162d062b
commit 292180b204
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
12 changed files with 1897 additions and 13 deletions

View file

@ -393,17 +393,21 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "\"PDSMan/Preview Content\"";
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = PDSMan;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -411,9 +415,12 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.PDSMan;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
@ -422,17 +429,21 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "\"PDSMan/Preview Content\"";
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = PDSMan;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -440,9 +451,12 @@
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.PDSMan;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};

View file

@ -9,13 +9,8 @@ import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
MainAppView()
.accentColor(.blue)
}
}

View file

@ -0,0 +1,68 @@
import Foundation
// Credentials for connecting to the PDS
struct PDSCredentials: Sendable {
var serverURL: String
var username: String
var password: String
init?(serverURL: String, username: String, password: String) {
// Validate inputs
guard !serverURL.isEmpty, !username.isEmpty, !password.isEmpty else {
return nil
}
self.serverURL = serverURL
self.username = username
self.password = password
}
}
// Invite code model
struct InviteCode: Identifiable, Sendable {
var id: String
var code: String { id } // For compatibility with the view
var uses: [CodeUse]?
var createdAt: Date
var disabled: Bool
var isDisabled: Bool { disabled } // For backwards compatibility
}
// Invite use model
struct CodeUse: Codable, Identifiable, Sendable {
var id: String { usedBy }
var usedBy: String
var usedAt: String
}
// User model
struct PDSUser: Identifiable, Hashable, Sendable {
var id: String // DID
var handle: String
var displayName: String
var description: String
var joinedAt: Date
var avatar: URL?
var isActive: Bool = true
// Add Hashable conformance
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: PDSUser, rhs: PDSUser) -> Bool {
lhs.id == rhs.id
}
}
// Auth state
enum AuthState: Sendable {
case loggedOut
case loggedIn
}
// View state
enum ViewTab: Sendable {
case inviteCodes
case userList
}

20
PDSMan/PDSApp.swift Normal file
View file

@ -0,0 +1,20 @@
import SwiftUI
@main
struct PDSApp: App {
@StateObject private var viewModel = PDSViewModel()
init() {
print("PDSApp: initializing")
}
var body: some Scene {
WindowGroup {
MainAppView()
.environmentObject(viewModel)
.onAppear {
print("PDSApp: root view appeared")
}
}
}
}

View file

@ -7,11 +7,11 @@
import SwiftUI
@main
// This app entry point is replaced by PDSApp.swift
struct PDSManApp: App {
var body: some Scene {
WindowGroup {
ContentView()
Text("This app entry point is not used.")
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,58 @@
import Foundation
import SwiftUI
import Combine
@MainActor
class PDSViewModel: ObservableObject {
@Published var pdsService = PDSService()
@Published var alertItem: AlertItem?
var errorMessage: String? {
pdsService.errorMessage
}
var isLoading: Bool {
pdsService.isLoading
}
var isAuthenticated: Bool {
get {
let value = pdsService.isAuthenticated
print("PDSViewModel: isAuthenticated getter called, value = \(value)")
return value
}
}
var users: [PDSUser] {
pdsService.users
}
var inviteCodes: [InviteCode] {
pdsService.inviteCodes
}
func login(serverURL: String, username: String, password: String) async {
print("PDSViewModel: login called")
if let credentials = PDSCredentials(serverURL: serverURL, username: username, password: password) {
let success = await pdsService.login(credentials: credentials)
print("PDSViewModel: login completed, success = \(success), isAuthenticated = \(pdsService.isAuthenticated)")
// Force update the view by triggering objectWillChange
objectWillChange.send()
} else {
pdsService.errorMessage = "Invalid credentials"
}
}
func logout() {
print("PDSViewModel: logout called")
pdsService.logout()
objectWillChange.send()
}
}
struct AlertItem: Identifiable {
let id = UUID()
let title: String
let message: String
}

View file

@ -0,0 +1,227 @@
import SwiftUI
struct InviteCodesView: View {
@EnvironmentObject var viewModel: PDSViewModel
@State private var isCreatingCode = false
@State private var isRefreshing = false
@State private var showDisabledCodes = false
// Split codes into active and disabled
var activeCodes: [InviteCode] {
return viewModel.inviteCodes.filter { !$0.disabled }
}
var disabledCodes: [InviteCode] {
return viewModel.inviteCodes.filter { $0.disabled }
}
var filteredCodes: [InviteCode] {
if showDisabledCodes {
return viewModel.inviteCodes
} else {
return viewModel.inviteCodes.filter { !$0.disabled }
}
}
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.inviteCodes.isEmpty {
ProgressView("Loading invite codes...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if filteredCodes.isEmpty {
VStack {
Text("No invite codes found")
.foregroundColor(.secondary)
Button("Refresh") {
refreshInviteCodes()
}
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
VStack(spacing: 0) {
List {
// Active codes section
Section(header: Text("Active Codes")) {
ForEach(activeCodes) { code in
InviteCodeRow(code: code)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.pdsService.disableInviteCode(code.id)
}
} label: {
Label("Disable Code", systemImage: "xmark.circle")
}
}
}
}
// Disabled codes section (only shown if toggle is on)
if showDisabledCodes && !disabledCodes.isEmpty {
Section(header: Text("Disabled Codes")) {
ForEach(disabledCodes) { code in
InviteCodeRow(code: code)
}
}
}
}
Toggle("Show Disabled Codes (\(disabledCodes.count))", isOn: $showDisabledCodes)
.padding()
.background(Color(.systemBackground))
}
}
}
.navigationTitle("Invite Codes")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
isCreatingCode = true
Task {
await viewModel.pdsService.createInviteCode()
isCreatingCode = false
}
} label: {
if isCreatingCode {
ProgressView()
} else {
Label("Create Code", systemImage: "plus")
}
}
.disabled(isCreatingCode)
}
ToolbarItem(placement: .navigationBarLeading) {
Button {
refreshInviteCodes()
} label: {
if isRefreshing {
ProgressView()
} else {
Label("Refresh", systemImage: "arrow.clockwise")
}
}
.disabled(isRefreshing)
}
}
.refreshable {
await viewModel.pdsService.fetchInviteCodes()
}
}
.task {
// Only fetch if we don't already have data
if viewModel.inviteCodes.isEmpty && !viewModel.isLoading {
await viewModel.pdsService.fetchInviteCodes()
}
}
.onAppear {
// Always attempt to refresh when view appears
if !viewModel.isLoading {
refreshInviteCodes()
}
}
}
private func refreshInviteCodes() {
isRefreshing = true
Task {
await viewModel.pdsService.fetchInviteCodes()
isRefreshing = false
}
}
}
struct InviteCodeRow: View {
let code: InviteCode
@State private var copySuccess = false
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: code.createdAt)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(code.id)
.font(.system(.headline, design: .monospaced))
.foregroundColor(code.disabled ? .gray : .primary)
Spacer()
if code.disabled {
Text("DISABLED")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red.opacity(0.2))
.foregroundColor(.red)
.cornerRadius(4)
} else {
Button {
UIPasteboard.general.string = code.id
withAnimation {
copySuccess = true
}
// Reset the success message after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
copySuccess = false
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: copySuccess ? "checkmark" : "doc.on.doc")
if copySuccess {
Text("Copied!")
}
}
.foregroundColor(copySuccess ? .green : .blue)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
(copySuccess ? Color.green : Color.blue)
.opacity(0.1)
.cornerRadius(4)
)
}
.buttonStyle(BorderlessButtonStyle())
}
}
HStack {
Text("Created: \(formattedDate)")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
if let uses = code.uses {
if uses.isEmpty {
Text("Unused")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.green.opacity(0.1))
.cornerRadius(4)
} else {
Text("Uses: \(uses.count)")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
}
}
}
.padding(.vertical, 6)
.opacity(code.disabled ? 0.7 : 1.0)
}
}

View file

@ -0,0 +1,134 @@
import SwiftUI
struct LoginView: View {
@EnvironmentObject var viewModel: PDSViewModel
@State private var serverURL = ""
@State private var password = ""
@State private var isLoggingIn = false
@State private var showingDebugInfo = false
@State private var debugLogs: [String] = []
var body: some View {
VStack(spacing: 20) {
Image(systemName: "server.rack")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
.foregroundColor(.blue)
Text("PDS Manager")
.font(.largeTitle)
.fontWeight(.bold)
VStack(alignment: .leading, spacing: 20) {
TextField("Server URL", text: $serverURL)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
.keyboardType(.URL)
SecureField("Admin Password", text: $password)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
.padding(.horizontal, 40)
Button {
login()
} label: {
if isLoggingIn {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(maxWidth: .infinity)
.padding()
} else {
Text("Login")
.frame(maxWidth: .infinity)
.padding()
}
}
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
.padding(.horizontal, 40)
.disabled(serverURL.isEmpty || password.isEmpty || isLoggingIn)
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.foregroundColor(.red)
.font(.callout)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
.padding(.vertical, 10)
.background(Color.red.opacity(0.1))
.cornerRadius(8)
}
Button("Toggle Debug Info") {
showingDebugInfo.toggle()
}
.font(.caption)
.padding(.top, 20)
if showingDebugInfo {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
ForEach(debugLogs, id: \.self) { log in
Text(log)
.font(.system(.caption, design: .monospaced))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.frame(height: 200)
.background(Color.black.opacity(0.05))
.cornerRadius(8)
.padding(.horizontal, 40)
}
Spacer()
}
.padding(.top, 50)
.onAppear {
// Add some example connection info
debugLogs.append("Example URLs:")
debugLogs.append("https://bsky.social - Main Bluesky server")
debugLogs.append("https://bsky.atri.dad - Your local server")
debugLogs.append("http://localhost:3000 - Local development PDS")
}
}
private func login() {
isLoggingIn = true
// Add debug info
debugLogs.append("Attempting login to: \(serverURL)")
// Sanitize the URL to ensure it has the correct format
var cleanURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanURL.hasPrefix("http://") && !cleanURL.hasPrefix("https://") {
cleanURL = "http://\(cleanURL)"
debugLogs.append("Added http:// prefix: \(cleanURL)")
}
// Remove trailing slash if present
if cleanURL.hasSuffix("/") {
cleanURL.removeLast()
debugLogs.append("Removed trailing slash: \(cleanURL)")
}
debugLogs.append("Using final URL: \(cleanURL)")
Task {
await viewModel.login(serverURL: cleanURL, username: "admin", password: password)
isLoggingIn = false
if let error = viewModel.errorMessage {
debugLogs.append("Login failed: \(error)")
} else {
debugLogs.append("Login successful!")
}
}
}
}

View file

@ -0,0 +1,50 @@
import SwiftUI
struct MainAppView: View {
@EnvironmentObject var viewModel: PDSViewModel
@State private var selectedTab = 0
var body: some View {
ZStack {
if viewModel.isAuthenticated {
authenticatedView
} else {
LoginView()
}
}
.alert(item: $viewModel.alertItem) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text("OK"))
)
}
}
var authenticatedView: some View {
NavigationView {
TabView(selection: $selectedTab) {
InviteCodesView()
.tabItem {
Image(systemName: "ticket")
Text("Invite Codes")
}
.tag(0)
UserListView()
.tabItem {
Image(systemName: "person.3")
Text("Users")
}
.tag(1)
}
.navigationTitle("PDS Manager")
.navigationBarItems(trailing: Button("Logout") {
viewModel.logout()
})
}
.onAppear {
print("Authenticated view appeared")
}
}
}

View file

@ -0,0 +1,260 @@
import SwiftUI
struct UserListView: View {
@EnvironmentObject var viewModel: PDSViewModel
@State private var editingUser: PDSUser? = nil
@State private var newHandle: String = ""
@State private var showingEditSheet = false
@State private var showingSuspendAlert = false
@State private var showingReactivateAlert = false
@State private var suspensionReason: String = ""
@State private var selectedUser: PDSUser? = nil
@State private var isRefreshing = false
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.users.isEmpty {
ProgressView("Loading users...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.users.isEmpty {
VStack {
Text("No users found")
.foregroundColor(.secondary)
Button("Refresh") {
refreshUsers()
}
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
ForEach(viewModel.users) { user in
UserRow(user: user)
.contentShape(Rectangle())
.onTapGesture {
selectedUser = user
editingUser = user
newHandle = user.handle
showingEditSheet = true
}
.contextMenu {
Button(action: {
selectedUser = user
editingUser = user
newHandle = user.handle
showingEditSheet = true
}) {
Label("Edit Handle", systemImage: "pencil")
}
if user.isActive {
Button(action: {
selectedUser = user
suspensionReason = ""
showingSuspendAlert = true
}) {
Label("Suspend Account", systemImage: "xmark.circle")
}
} else {
Button(action: {
selectedUser = user
showingReactivateAlert = true
}) {
Label("Reactivate Account", systemImage: "checkmark.circle")
}
}
}
}
}
}
}
.navigationTitle("Users")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
refreshUsers()
} label: {
if isRefreshing {
ProgressView()
} else {
Label("Refresh", systemImage: "arrow.clockwise")
}
}
.disabled(isRefreshing)
}
}
.refreshable {
await viewModel.pdsService.fetchUsers()
}
.sheet(isPresented: $showingEditSheet) {
if let user = editingUser {
NavigationView {
Form {
Section(header: Text("Edit User Handle")) {
TextField("Handle", text: $newHandle)
HStack {
Button("Cancel") {
showingEditSheet = false
}
Spacer()
Button("Save") {
Task {
if await viewModel.pdsService.editUserHandle(userId: user.id, newHandle: newHandle) {
showingEditSheet = false
}
}
}
.disabled(newHandle.isEmpty || newHandle == user.handle)
}
}
}
.navigationTitle("Edit \(user.displayName)")
}
}
}
.alert("Suspend User", isPresented: $showingSuspendAlert) {
TextField("Reason for suspension", text: $suspensionReason)
Button("Cancel", role: .cancel) { }
Button("Suspend", role: .destructive) {
if let user = selectedUser {
Task {
await viewModel.pdsService.suspendUser(userId: user.id, reason: suspensionReason)
await viewModel.pdsService.fetchUsers() // Refresh after suspension
}
}
}
} message: {
if let user = selectedUser {
Text("Are you sure you want to suspend \(user.displayName)'s account? This will prevent them from using the service.")
}
}
.alert("Reactivate User", isPresented: $showingReactivateAlert) {
Button("Cancel", role: .cancel) { }
Button("Reactivate") {
if let user = selectedUser {
Task {
await viewModel.pdsService.reactivateUser(userId: user.id)
await viewModel.pdsService.fetchUsers() // Refresh after reactivation
}
}
}
} message: {
if let user = selectedUser {
Text("Are you sure you want to reactivate \(user.displayName)'s account?")
}
}
}
.task {
// Only fetch if we don't already have data
if viewModel.users.isEmpty && !viewModel.isLoading {
await viewModel.pdsService.fetchUsers()
}
}
.onAppear {
// Always attempt to refresh when view appears
if !viewModel.isLoading {
refreshUsers()
}
}
}
private func refreshUsers() {
isRefreshing = true
Task {
await viewModel.pdsService.fetchUsers()
isRefreshing = false
}
}
}
struct UserRow: View {
let user: PDSUser
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return "Joined: \(formatter.string(from: user.joinedAt))"
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
if let avatarURL = user.avatar {
AsyncImage(url: avatarURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}
.frame(width: 60, height: 60)
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
)
} else {
Image(systemName: "person.circle.fill")
.resizable()
.foregroundColor(.gray.opacity(0.7))
.frame(width: 60, height: 60)
}
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(user.displayName)
.font(.title3)
.fontWeight(.semibold)
.lineLimit(1)
if !user.isActive {
Text("SUSPENDED")
.font(.caption)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(4)
}
}
Text("@\(user.handle)")
.font(.subheadline)
.foregroundColor(.blue)
.lineLimit(1)
}
}
if !user.description.isEmpty {
Text(user.description)
.font(.subheadline)
.foregroundColor(.primary)
.lineLimit(3)
.padding(.top, 2)
}
HStack(spacing: 12) {
Text(formattedDate)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(user.id)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 160)
.opacity(0.7)
}
}
.padding(.vertical, 10)
.opacity(user.isActive ? 1.0 : 0.7)
}
}

6
README.md Normal file
View file

@ -0,0 +1,6 @@
# PDSMan
Privacy Policy
This app does not collect any personal information. All data processing is performed locally on your device. The only data transmitted is crash logs sent to Apple for the purpose of improving the app.