feat: added error pages c3064961
Steve · 2025-12-28 10:54 4 file(s) · +507 −13
.gitignore +1 −0
1 1
CLAUDE.md
2 +
.claude
Titan.xcodeproj/project.pbxproj +2 −2
412 412
					"$(inherited)",
413 413
					"@executable_path/Frameworks",
414 414
				);
415 -
				MARKETING_VERSION = 0.1;
415 +
				MARKETING_VERSION = 0.2;
416 416
				PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.TitanII;
417 417
				PRODUCT_NAME = "$(TARGET_NAME)";
418 418
				STRING_CATALOG_GENERATE_SYMBOLS = YES;
451 451
					"$(inherited)",
452 452
					"@executable_path/Frameworks",
453 453
				);
454 -
				MARKETING_VERSION = 0.1;
454 +
				MARKETING_VERSION = 0.2;
455 455
				PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.TitanII;
456 456
				PRODUCT_NAME = "$(TARGET_NAME)";
457 457
				STRING_CATALOG_GENERATE_SYMBOLS = YES;
Titan/Views/ContentView.swift +33 −11
47 47
    // URL input focus state
48 48
    @FocusState private var isURLFocused: Bool
49 49
50 +
    // Error state
51 +
    @State private var currentError: GeminiErrorType?
52 +
50 53
    private let maxRedirects = 5
51 54
52 55
    var body: some View {
53 56
        ScrollViewReader { proxy in
54 57
            ScrollView {
55 -
                TitanContentView(content: responseText, baseURL: urlText, onLinkTap: { url in
56 -
                    navigateTo(url)
57 -
                })
58 -
                .frame(maxWidth: .infinity, alignment: .leading)
59 -
                .padding(.horizontal, 4)
60 -
                .id("top")
58 +
                if let error = currentError {
59 +
                    ErrorPageView(errorType: error, onRetry: {
60 +
                        navigateTo(urlText)
61 +
                    })
62 +
                    .id("top")
63 +
                } else {
64 +
                    TitanContentView(content: responseText, baseURL: urlText, onLinkTap: { url in
65 +
                        navigateTo(url)
66 +
                    })
67 +
                    .frame(maxWidth: .infinity, alignment: .leading)
68 +
                    .padding(.horizontal, 4)
69 +
                    .id("top")
70 +
                }
61 71
            }
62 72
            .onChange(of: responseText) {
63 73
                withAnimation {
294 304
295 305
                switch response.statusCategory {
296 306
                case .success:
307 +
                    currentError = nil
297 308
                    let mimeType = response.meta
298 309
299 310
                    if MediaType.isMediaContent(mimeType) {
323 334
                        saveCurrentTabState()
324 335
                    }
325 336
                case .input:
337 +
                    currentError = nil
326 338
                    pendingInputURL = finalURL
327 339
                    inputPromptText = response.meta
328 340
                    inputIsSensitive = response.statusCode == 11
329 341
                    showInputPrompt = true
330 342
                case .redirect:
331 -
                    responseText = "Too many redirects"
343 +
                    currentError = .tooManyRedirects
332 344
                case .temporaryFailure:
333 -
                    responseText = "Temporary failure (\(response.statusCode)): \(response.meta)"
345 +
                    currentError = .temporaryFailure(code: response.statusCode, meta: response.meta)
334 346
                case .permanentFailure:
335 -
                    responseText = "Error (\(response.statusCode)): \(response.meta)"
347 +
                    currentError = .permanentFailure(code: response.statusCode, meta: response.meta)
336 348
                case .clientCertificate:
337 -
                    responseText = "Client certificate required: \(response.meta)"
349 +
                    currentError = .clientCertificate(code: response.statusCode, meta: response.meta)
338 350
                }
339 351
                isLoading = false
340 352
            } catch is CancellationError {
343 355
            } catch let error as GeminiError where error == .cancelled {
344 356
                // Request was cancelled, don't update UI
345 357
                return
358 +
            } catch let error as GeminiError {
359 +
                switch error {
360 +
                case .invalidResponse:
361 +
                    currentError = .invalidResponse
362 +
                case .invalidURL:
363 +
                    currentError = .invalidURL
364 +
                case .cancelled:
365 +
                    return
366 +
                }
367 +
                isLoading = false
346 368
            } catch {
347 -
                responseText = "Error: \(error.localizedDescription)"
369 +
                currentError = .networkError(error.localizedDescription)
348 370
                isLoading = false
349 371
            }
350 372
        }
Titan/Views/ErrorPageView.swift (added) +471 −0
1 +
//
2 +
//  ErrorPageView.swift
3 +
//  Titan
4 +
//
5 +
6 +
import SwiftUI
7 +
8 +
enum GeminiErrorType {
9 +
    case temporaryFailure(code: Int, meta: String)
10 +
    case permanentFailure(code: Int, meta: String)
11 +
    case clientCertificate(code: Int, meta: String)
12 +
    case tooManyRedirects
13 +
    case networkError(String)
14 +
    case invalidResponse
15 +
    case invalidURL
16 +
17 +
    var title: String {
18 +
        switch self {
19 +
        case .temporaryFailure(let code, _):
20 +
            switch code {
21 +
            case 40: return "Temporary Failure"
22 +
            case 41: return "Server Unavailable"
23 +
            case 42: return "CGI Error"
24 +
            case 43: return "Proxy Error"
25 +
            case 44: return "Slow Down"
26 +
            default: return "Temporary Failure"
27 +
            }
28 +
        case .permanentFailure(let code, _):
29 +
            switch code {
30 +
            case 50: return "Permanent Failure"
31 +
            case 51: return "Not Found"
32 +
            case 52: return "Gone"
33 +
            case 53: return "Proxy Request Refused"
34 +
            case 59: return "Bad Request"
35 +
            default: return "Error"
36 +
            }
37 +
        case .clientCertificate(let code, _):
38 +
            switch code {
39 +
            case 60: return "Certificate Required"
40 +
            case 61: return "Certificate Not Authorized"
41 +
            case 62: return "Certificate Not Valid"
42 +
            default: return "Certificate Error"
43 +
            }
44 +
        case .tooManyRedirects:
45 +
            return "Too Many Redirects"
46 +
        case .networkError:
47 +
            return "Connection Failed"
48 +
        case .invalidResponse:
49 +
            return "Invalid Response"
50 +
        case .invalidURL:
51 +
            return "Invalid URL"
52 +
        }
53 +
    }
54 +
55 +
    var statusCode: String? {
56 +
        switch self {
57 +
        case .temporaryFailure(let code, _),
58 +
             .permanentFailure(let code, _),
59 +
             .clientCertificate(let code, _):
60 +
            return "(\(code))"
61 +
        default:
62 +
            return nil
63 +
        }
64 +
    }
65 +
66 +
    var meta: String? {
67 +
        switch self {
68 +
        case .temporaryFailure(_, let meta),
69 +
             .permanentFailure(_, let meta),
70 +
             .clientCertificate(_, let meta):
71 +
            return meta.isEmpty ? nil : meta
72 +
        case .networkError(let message):
73 +
            return message
74 +
        default:
75 +
            return nil
76 +
        }
77 +
    }
78 +
79 +
    var explanation: String {
80 +
        switch self {
81 +
        case .temporaryFailure(let code, _):
82 +
            switch code {
83 +
            case 40:
84 +
                return "The server encountered a temporary problem and couldn't complete your request."
85 +
            case 41:
86 +
                return "The server is currently unavailable, possibly due to maintenance or high load."
87 +
            case 42:
88 +
                return "A script on the server failed to execute properly."
89 +
            case 43:
90 +
                return "There was an error communicating through a proxy server."
91 +
            case 44:
92 +
                return "You're making requests too quickly. The server needs you to slow down."
93 +
            default:
94 +
                return "The server encountered a temporary issue processing your request."
95 +
            }
96 +
        case .permanentFailure(let code, _):
97 +
            switch code {
98 +
            case 50:
99 +
                return "The server cannot fulfill this request. The issue is permanent."
100 +
            case 51:
101 +
                return "The page you requested doesn't exist on this server."
102 +
            case 52:
103 +
                return "This content used to exist but has been permanently removed."
104 +
            case 53:
105 +
                return "This server does not accept proxy requests."
106 +
            case 59:
107 +
                return "The server couldn't understand your request. The URL may be malformed."
108 +
            default:
109 +
                return "The server cannot process this request."
110 +
            }
111 +
        case .clientCertificate(let code, _):
112 +
            switch code {
113 +
            case 60:
114 +
                return "This page requires a client certificate for authentication."
115 +
            case 61:
116 +
                return "Your certificate is not authorized to access this resource."
117 +
            case 62:
118 +
                return "Your certificate is invalid or has expired."
119 +
            default:
120 +
                return "There's an issue with certificate authentication."
121 +
            }
122 +
        case .tooManyRedirects:
123 +
            return "The page redirected too many times, possibly in a loop."
124 +
        case .networkError:
125 +
            return "Unable to establish a connection to the server."
126 +
        case .invalidResponse:
127 +
            return "The server sent a response that couldn't be understood."
128 +
        case .invalidURL:
129 +
            return "The URL you entered isn't valid for the Gemini protocol."
130 +
        }
131 +
    }
132 +
133 +
    var suggestions: [String] {
134 +
        switch self {
135 +
        case .temporaryFailure(let code, _):
136 +
            switch code {
137 +
            case 41:
138 +
                return [
139 +
                    "Wait a few minutes and try again",
140 +
                    "Check if the site is down for others",
141 +
                    "Try a different page on the same site"
142 +
                ]
143 +
            case 44:
144 +
                return [
145 +
                    "Wait before making another request",
146 +
                    "Avoid refreshing repeatedly"
147 +
                ]
148 +
            default:
149 +
                return [
150 +
                    "Try again in a few moments",
151 +
                    "The issue may resolve itself"
152 +
                ]
153 +
            }
154 +
        case .permanentFailure(let code, _):
155 +
            switch code {
156 +
            case 51:
157 +
                return [
158 +
                    "Check the URL for typos",
159 +
                    "The page may have moved",
160 +
                    "Try the site's homepage"
161 +
                ]
162 +
            case 52:
163 +
                return [
164 +
                    "This content has been removed",
165 +
                    "Try searching for similar content",
166 +
                    "Check for an archived version"
167 +
                ]
168 +
            case 59:
169 +
                return [
170 +
                    "Check the URL format",
171 +
                    "Ensure special characters are encoded",
172 +
                    "Try a simpler URL"
173 +
                ]
174 +
            default:
175 +
                return [
176 +
                    "Double-check the URL",
177 +
                    "Try a different page"
178 +
                ]
179 +
            }
180 +
        case .clientCertificate(let code, _):
181 +
            switch code {
182 +
            case 60:
183 +
                return [
184 +
                    "Client certificates are not yet supported",
185 +
                    "Some Gemini sites require authentication"
186 +
                ]
187 +
            default:
188 +
                return [
189 +
                    "Certificate authentication failed",
190 +
                    "Contact the site administrator"
191 +
                ]
192 +
            }
193 +
        case .tooManyRedirects:
194 +
            return [
195 +
                "The site may have a configuration issue",
196 +
                "Try accessing the page directly",
197 +
                "Contact the site administrator"
198 +
            ]
199 +
        case .networkError:
200 +
            return [
201 +
                "Check your internet connection",
202 +
                "Verify the hostname is correct",
203 +
                "The server may be offline"
204 +
            ]
205 +
        case .invalidResponse:
206 +
            return [
207 +
                "The server may not support Gemini",
208 +
                "Try again later",
209 +
                "Report the issue to the site owner"
210 +
            ]
211 +
        case .invalidURL:
212 +
            return [
213 +
                "URLs should start with gemini://",
214 +
                "Check for typos in the address",
215 +
                "Ensure the hostname is valid"
216 +
            ]
217 +
        }
218 +
    }
219 +
220 +
    var asciiArt: String {
221 +
        switch self {
222 +
        case .temporaryFailure(let code, _):
223 +
            if code == 44 {
224 +
                // Hourglass for "slow down"
225 +
                return """
226 +
                    ╭━━━━━━━╮
227 +
                    │░░░░░░░│
228 +
                    ╰┬─────┬╯
229 +
                     │░░░░░│
230 +
                     │░░░░░│
231 +
                    ╭┴─────┴╮
232 +
                    │       │
233 +
                    ╰━━━━━━━╯
234 +
                """
235 +
            }
236 +
            // Clock for temporary issues
237 +
            return """
238 +
                  ╭───────╮
239 +
                 ╱    │    ╲
240 +
                │     │     │
241 +
                │     ╰──   │
242 +
                │           │
243 +
                 ╲         ╱
244 +
                  ╰───────╯
245 +
            """
246 +
        case .permanentFailure(let code, _):
247 +
            if code == 51 {
248 +
                // Question mark for not found
249 +
                return """
250 +
                   ╭━━━━━╮
251 +
                   │ ??? │
252 +
                   ╰──┬──╯
253 +
254 +
                    ╭─┴─╮
255 +
                    │ ? │
256 +
                    ╰───╯
257 +
                """
258 +
            }
259 +
            if code == 52 {
260 +
                // Ghost for "gone"
261 +
                return """
262 +
                    ╭─────╮
263 +
                   ╱ ◠   ◠ ╲
264 +
                  │    ▽    │
265 +
                  │         │
266 +
                   ╲ ╱ ╲ ╱ ╱
267 +
                    ╵   ╵
268 +
                """
269 +
            }
270 +
            // X mark for errors
271 +
            return """
272 +
                  ╲       ╱
273 +
                   ╲     ╱
274 +
                    ╲   ╱
275 +
                     ╲ ╱
276 +
                     ╱ ╲
277 +
                    ╱   ╲
278 +
                   ╱     ╲
279 +
                  ╱       ╲
280 +
            """
281 +
        case .clientCertificate:
282 +
            // Lock icon
283 +
            return """
284 +
                   ╭─────╮
285 +
                   │     │
286 +
                 ╭─┴─────┴─╮
287 +
                 │  ┌───┐  │
288 +
                 │  │ ◉ │  │
289 +
                 │  └─┬─┘  │
290 +
                 ╰────┴────╯
291 +
            """
292 +
        case .tooManyRedirects:
293 +
            // Circular arrows
294 +
            return """
295 +
                  ╭──────╮
296 +
                 ╱   ──▶  ╲
297 +
                │ ▲       │
298 +
                │         ▼
299 +
                 ╲  ◀──   ╱
300 +
                  ╰──────╯
301 +
            """
302 +
        case .networkError:
303 +
            // Disconnected plug
304 +
            return """
305 +
                 ╭───╮
306 +
                 │ ● │
307 +
                 │ ● │╶╶╶╮
308 +
                 ╰───╯   ┊
309 +
310 +
                 ╭───╮   ┊
311 +
                 │ ○ │╶╶╶╯
312 +
                 │ ○ │
313 +
                 ╰───╯
314 +
            """
315 +
        case .invalidResponse:
316 +
            // Broken document
317 +
            return """
318 +
                 ╭───────╮
319 +
                 │ ≋≋≋≋≋ │
320 +
                 │ ≋≋≋ ╱─┤
321 +
                 ├───╱   │
322 +
                 │   ╲───┤
323 +
                 │ ≋≋ ╲  │
324 +
                 ╰───────╯
325 +
            """
326 +
        case .invalidURL:
327 +
            // Broken link
328 +
            return """
329 +
                 ╭───╮
330 +
                ╱    ╲────╮
331 +
                ╲    ╱    │
332 +
                 ╰─╳─╯    │
333 +
                     ╭─╳──╯
334 +
                     │╱    ╲
335 +
                     ╰────╱
336 +
            """
337 +
        }
338 +
    }
339 +
}
340 +
341 +
struct ErrorPageView: View {
342 +
    let errorType: GeminiErrorType
343 +
    let onRetry: (() -> Void)?
344 +
345 +
    @EnvironmentObject private var themeSettings: ThemeSettings
346 +
347 +
    init(errorType: GeminiErrorType, onRetry: (() -> Void)? = nil) {
348 +
        self.errorType = errorType
349 +
        self.onRetry = onRetry
350 +
    }
351 +
352 +
    private var errorColor: Color {
353 +
        switch errorType {
354 +
        case .temporaryFailure:
355 +
            return .orange
356 +
        case .permanentFailure:
357 +
            return .red
358 +
        case .clientCertificate:
359 +
            return .purple
360 +
        case .tooManyRedirects:
361 +
            return .yellow
362 +
        case .networkError, .invalidResponse, .invalidURL:
363 +
            return .gray
364 +
        }
365 +
    }
366 +
367 +
    var body: some View {
368 +
        VStack(spacing: 24) {
369 +
            // ASCII Art
370 +
            Text(errorType.asciiArt)
371 +
                .font(.system(size: 14, design: .monospaced))
372 +
                .foregroundColor(errorColor)
373 +
                .multilineTextAlignment(.center)
374 +
375 +
            // Title and status code
376 +
            VStack(spacing: 4) {
377 +
                Text(errorType.title)
378 +
                    .font(.system(.title2, design: .rounded))
379 +
                    .fontWeight(.bold)
380 +
                    .foregroundColor(themeSettings.textColor)
381 +
382 +
                if let code = errorType.statusCode {
383 +
                    Text(code)
384 +
                        .font(.system(.subheadline, design: .monospaced))
385 +
                        .foregroundColor(.secondary)
386 +
                }
387 +
            }
388 +
389 +
            // Server message if present
390 +
            if let meta = errorType.meta {
391 +
                Text(meta)
392 +
                    .font(.system(.caption, design: .monospaced))
393 +
                    .foregroundColor(.secondary)
394 +
                    .padding(.horizontal, 16)
395 +
                    .padding(.vertical, 8)
396 +
                    .background(
397 +
                        RoundedRectangle(cornerRadius: 6)
398 +
                            .fill(Color.secondary.opacity(0.1))
399 +
                    )
400 +
            }
401 +
402 +
            // Explanation
403 +
            Text(errorType.explanation)
404 +
                .font(.system(.body, design: .default))
405 +
                .foregroundColor(themeSettings.textColor.opacity(0.8))
406 +
                .multilineTextAlignment(.center)
407 +
                .padding(.horizontal, 24)
408 +
409 +
            // Suggestions
410 +
            VStack(alignment: .leading, spacing: 8) {
411 +
                ForEach(errorType.suggestions, id: \.self) { suggestion in
412 +
                    HStack(alignment: .top, spacing: 8) {
413 +
                        Text("•")
414 +
                            .foregroundColor(errorColor)
415 +
                        Text(suggestion)
416 +
                            .font(.system(.callout))
417 +
                            .foregroundColor(.secondary)
418 +
                    }
419 +
                }
420 +
            }
421 +
            .padding(.horizontal, 32)
422 +
423 +
            // Retry button for temporary errors
424 +
            if let onRetry = onRetry, canRetry {
425 +
                Button(action: onRetry) {
426 +
                    HStack {
427 +
                        Image(systemName: "arrow.clockwise")
428 +
                        Text("Try Again")
429 +
                    }
430 +
                    .font(.system(.body, weight: .medium))
431 +
                    .foregroundColor(.white)
432 +
                    .padding(.horizontal, 24)
433 +
                    .padding(.vertical, 12)
434 +
                    .background(
435 +
                        Capsule()
436 +
                            .fill(themeSettings.accentColor)
437 +
                    )
438 +
                }
439 +
                .padding(.top, 8)
440 +
            }
441 +
        }
442 +
        .padding(.vertical, 40)
443 +
        .frame(maxWidth: .infinity)
444 +
    }
445 +
446 +
    private var canRetry: Bool {
447 +
        switch errorType {
448 +
        case .temporaryFailure, .networkError:
449 +
            return true
450 +
        default:
451 +
            return false
452 +
        }
453 +
    }
454 +
}
455 +
456 +
#Preview {
457 +
    ScrollView {
458 +
        VStack(spacing: 40) {
459 +
            ErrorPageView(errorType: .permanentFailure(code: 51, meta: "Resource not found"))
460 +
461 +
            Divider()
462 +
463 +
            ErrorPageView(errorType: .temporaryFailure(code: 44, meta: "Please wait 30 seconds"), onRetry: {})
464 +
465 +
            Divider()
466 +
467 +
            ErrorPageView(errorType: .networkError("Connection refused"))
468 +
        }
469 +
    }
470 +
    .environmentObject(ThemeSettings())
471 +
}