feat: init 7e7f6d31
Steve · 2025-12-20 23:40 13 file(s) · +1347 −0
.gitignore (added) +1 −0
1 +
CLAUDE.md
Gemini.xcodeproj/project.pbxproj (added) +583 −0
1 +
// !$*UTF8*$!
2 +
{
3 +
	archiveVersion = 1;
4 +
	classes = {
5 +
	};
6 +
	objectVersion = 77;
7 +
	objects = {
8 +
9 +
/* Begin PBXContainerItemProxy section */
10 +
		0C54249A2EF79293001BB2ED /* PBXContainerItemProxy */ = {
11 +
			isa = PBXContainerItemProxy;
12 +
			containerPortal = 0C5424842EF79292001BB2ED /* Project object */;
13 +
			proxyType = 1;
14 +
			remoteGlobalIDString = 0C54248B2EF79292001BB2ED;
15 +
			remoteInfo = Gemini;
16 +
		};
17 +
		0C5424A42EF79293001BB2ED /* PBXContainerItemProxy */ = {
18 +
			isa = PBXContainerItemProxy;
19 +
			containerPortal = 0C5424842EF79292001BB2ED /* Project object */;
20 +
			proxyType = 1;
21 +
			remoteGlobalIDString = 0C54248B2EF79292001BB2ED;
22 +
			remoteInfo = Gemini;
23 +
		};
24 +
/* End PBXContainerItemProxy section */
25 +
26 +
/* Begin PBXFileReference section */
27 +
		0C54248C2EF79292001BB2ED /* Gemini.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gemini.app; sourceTree = BUILT_PRODUCTS_DIR; };
28 +
		0C5424992EF79293001BB2ED /* GeminiTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GeminiTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
29 +
		0C5424A32EF79293001BB2ED /* GeminiUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GeminiUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
30 +
/* End PBXFileReference section */
31 +
32 +
/* Begin PBXFileSystemSynchronizedRootGroup section */
33 +
		0C54248E2EF79292001BB2ED /* Gemini */ = {
34 +
			isa = PBXFileSystemSynchronizedRootGroup;
35 +
			path = Gemini;
36 +
			sourceTree = "<group>";
37 +
		};
38 +
		0C54249C2EF79293001BB2ED /* GeminiTests */ = {
39 +
			isa = PBXFileSystemSynchronizedRootGroup;
40 +
			path = GeminiTests;
41 +
			sourceTree = "<group>";
42 +
		};
43 +
		0C5424A62EF79293001BB2ED /* GeminiUITests */ = {
44 +
			isa = PBXFileSystemSynchronizedRootGroup;
45 +
			path = GeminiUITests;
46 +
			sourceTree = "<group>";
47 +
		};
48 +
/* End PBXFileSystemSynchronizedRootGroup section */
49 +
50 +
/* Begin PBXFrameworksBuildPhase section */
51 +
		0C5424892EF79292001BB2ED /* Frameworks */ = {
52 +
			isa = PBXFrameworksBuildPhase;
53 +
			buildActionMask = 2147483647;
54 +
			files = (
55 +
			);
56 +
			runOnlyForDeploymentPostprocessing = 0;
57 +
		};
58 +
		0C5424962EF79293001BB2ED /* Frameworks */ = {
59 +
			isa = PBXFrameworksBuildPhase;
60 +
			buildActionMask = 2147483647;
61 +
			files = (
62 +
			);
63 +
			runOnlyForDeploymentPostprocessing = 0;
64 +
		};
65 +
		0C5424A02EF79293001BB2ED /* Frameworks */ = {
66 +
			isa = PBXFrameworksBuildPhase;
67 +
			buildActionMask = 2147483647;
68 +
			files = (
69 +
			);
70 +
			runOnlyForDeploymentPostprocessing = 0;
71 +
		};
72 +
/* End PBXFrameworksBuildPhase section */
73 +
74 +
/* Begin PBXGroup section */
75 +
		0C5424832EF79292001BB2ED = {
76 +
			isa = PBXGroup;
77 +
			children = (
78 +
				0C54248E2EF79292001BB2ED /* Gemini */,
79 +
				0C54249C2EF79293001BB2ED /* GeminiTests */,
80 +
				0C5424A62EF79293001BB2ED /* GeminiUITests */,
81 +
				0C54248D2EF79292001BB2ED /* Products */,
82 +
			);
83 +
			sourceTree = "<group>";
84 +
		};
85 +
		0C54248D2EF79292001BB2ED /* Products */ = {
86 +
			isa = PBXGroup;
87 +
			children = (
88 +
				0C54248C2EF79292001BB2ED /* Gemini.app */,
89 +
				0C5424992EF79293001BB2ED /* GeminiTests.xctest */,
90 +
				0C5424A32EF79293001BB2ED /* GeminiUITests.xctest */,
91 +
			);
92 +
			name = Products;
93 +
			sourceTree = "<group>";
94 +
		};
95 +
/* End PBXGroup section */
96 +
97 +
/* Begin PBXNativeTarget section */
98 +
		0C54248B2EF79292001BB2ED /* Gemini */ = {
99 +
			isa = PBXNativeTarget;
100 +
			buildConfigurationList = 0C5424AD2EF79293001BB2ED /* Build configuration list for PBXNativeTarget "Gemini" */;
101 +
			buildPhases = (
102 +
				0C5424882EF79292001BB2ED /* Sources */,
103 +
				0C5424892EF79292001BB2ED /* Frameworks */,
104 +
				0C54248A2EF79292001BB2ED /* Resources */,
105 +
			);
106 +
			buildRules = (
107 +
			);
108 +
			dependencies = (
109 +
			);
110 +
			fileSystemSynchronizedGroups = (
111 +
				0C54248E2EF79292001BB2ED /* Gemini */,
112 +
			);
113 +
			name = Gemini;
114 +
			packageProductDependencies = (
115 +
			);
116 +
			productName = Gemini;
117 +
			productReference = 0C54248C2EF79292001BB2ED /* Gemini.app */;
118 +
			productType = "com.apple.product-type.application";
119 +
		};
120 +
		0C5424982EF79293001BB2ED /* GeminiTests */ = {
121 +
			isa = PBXNativeTarget;
122 +
			buildConfigurationList = 0C5424B02EF79293001BB2ED /* Build configuration list for PBXNativeTarget "GeminiTests" */;
123 +
			buildPhases = (
124 +
				0C5424952EF79293001BB2ED /* Sources */,
125 +
				0C5424962EF79293001BB2ED /* Frameworks */,
126 +
				0C5424972EF79293001BB2ED /* Resources */,
127 +
			);
128 +
			buildRules = (
129 +
			);
130 +
			dependencies = (
131 +
				0C54249B2EF79293001BB2ED /* PBXTargetDependency */,
132 +
			);
133 +
			fileSystemSynchronizedGroups = (
134 +
				0C54249C2EF79293001BB2ED /* GeminiTests */,
135 +
			);
136 +
			name = GeminiTests;
137 +
			packageProductDependencies = (
138 +
			);
139 +
			productName = GeminiTests;
140 +
			productReference = 0C5424992EF79293001BB2ED /* GeminiTests.xctest */;
141 +
			productType = "com.apple.product-type.bundle.unit-test";
142 +
		};
143 +
		0C5424A22EF79293001BB2ED /* GeminiUITests */ = {
144 +
			isa = PBXNativeTarget;
145 +
			buildConfigurationList = 0C5424B32EF79293001BB2ED /* Build configuration list for PBXNativeTarget "GeminiUITests" */;
146 +
			buildPhases = (
147 +
				0C54249F2EF79293001BB2ED /* Sources */,
148 +
				0C5424A02EF79293001BB2ED /* Frameworks */,
149 +
				0C5424A12EF79293001BB2ED /* Resources */,
150 +
			);
151 +
			buildRules = (
152 +
			);
153 +
			dependencies = (
154 +
				0C5424A52EF79293001BB2ED /* PBXTargetDependency */,
155 +
			);
156 +
			fileSystemSynchronizedGroups = (
157 +
				0C5424A62EF79293001BB2ED /* GeminiUITests */,
158 +
			);
159 +
			name = GeminiUITests;
160 +
			packageProductDependencies = (
161 +
			);
162 +
			productName = GeminiUITests;
163 +
			productReference = 0C5424A32EF79293001BB2ED /* GeminiUITests.xctest */;
164 +
			productType = "com.apple.product-type.bundle.ui-testing";
165 +
		};
166 +
/* End PBXNativeTarget section */
167 +
168 +
/* Begin PBXProject section */
169 +
		0C5424842EF79292001BB2ED /* Project object */ = {
170 +
			isa = PBXProject;
171 +
			attributes = {
172 +
				BuildIndependentTargetsInParallel = 1;
173 +
				LastSwiftUpdateCheck = 2610;
174 +
				LastUpgradeCheck = 2610;
175 +
				TargetAttributes = {
176 +
					0C54248B2EF79292001BB2ED = {
177 +
						CreatedOnToolsVersion = 26.1.1;
178 +
					};
179 +
					0C5424982EF79293001BB2ED = {
180 +
						CreatedOnToolsVersion = 26.1.1;
181 +
						TestTargetID = 0C54248B2EF79292001BB2ED;
182 +
					};
183 +
					0C5424A22EF79293001BB2ED = {
184 +
						CreatedOnToolsVersion = 26.1.1;
185 +
						TestTargetID = 0C54248B2EF79292001BB2ED;
186 +
					};
187 +
				};
188 +
			};
189 +
			buildConfigurationList = 0C5424872EF79292001BB2ED /* Build configuration list for PBXProject "Gemini" */;
190 +
			developmentRegion = en;
191 +
			hasScannedForEncodings = 0;
192 +
			knownRegions = (
193 +
				en,
194 +
				Base,
195 +
			);
196 +
			mainGroup = 0C5424832EF79292001BB2ED;
197 +
			minimizedProjectReferenceProxies = 1;
198 +
			preferredProjectObjectVersion = 77;
199 +
			productRefGroup = 0C54248D2EF79292001BB2ED /* Products */;
200 +
			projectDirPath = "";
201 +
			projectRoot = "";
202 +
			targets = (
203 +
				0C54248B2EF79292001BB2ED /* Gemini */,
204 +
				0C5424982EF79293001BB2ED /* GeminiTests */,
205 +
				0C5424A22EF79293001BB2ED /* GeminiUITests */,
206 +
			);
207 +
		};
208 +
/* End PBXProject section */
209 +
210 +
/* Begin PBXResourcesBuildPhase section */
211 +
		0C54248A2EF79292001BB2ED /* Resources */ = {
212 +
			isa = PBXResourcesBuildPhase;
213 +
			buildActionMask = 2147483647;
214 +
			files = (
215 +
			);
216 +
			runOnlyForDeploymentPostprocessing = 0;
217 +
		};
218 +
		0C5424972EF79293001BB2ED /* Resources */ = {
219 +
			isa = PBXResourcesBuildPhase;
220 +
			buildActionMask = 2147483647;
221 +
			files = (
222 +
			);
223 +
			runOnlyForDeploymentPostprocessing = 0;
224 +
		};
225 +
		0C5424A12EF79293001BB2ED /* Resources */ = {
226 +
			isa = PBXResourcesBuildPhase;
227 +
			buildActionMask = 2147483647;
228 +
			files = (
229 +
			);
230 +
			runOnlyForDeploymentPostprocessing = 0;
231 +
		};
232 +
/* End PBXResourcesBuildPhase section */
233 +
234 +
/* Begin PBXSourcesBuildPhase section */
235 +
		0C5424882EF79292001BB2ED /* Sources */ = {
236 +
			isa = PBXSourcesBuildPhase;
237 +
			buildActionMask = 2147483647;
238 +
			files = (
239 +
			);
240 +
			runOnlyForDeploymentPostprocessing = 0;
241 +
		};
242 +
		0C5424952EF79293001BB2ED /* Sources */ = {
243 +
			isa = PBXSourcesBuildPhase;
244 +
			buildActionMask = 2147483647;
245 +
			files = (
246 +
			);
247 +
			runOnlyForDeploymentPostprocessing = 0;
248 +
		};
249 +
		0C54249F2EF79293001BB2ED /* Sources */ = {
250 +
			isa = PBXSourcesBuildPhase;
251 +
			buildActionMask = 2147483647;
252 +
			files = (
253 +
			);
254 +
			runOnlyForDeploymentPostprocessing = 0;
255 +
		};
256 +
/* End PBXSourcesBuildPhase section */
257 +
258 +
/* Begin PBXTargetDependency section */
259 +
		0C54249B2EF79293001BB2ED /* PBXTargetDependency */ = {
260 +
			isa = PBXTargetDependency;
261 +
			target = 0C54248B2EF79292001BB2ED /* Gemini */;
262 +
			targetProxy = 0C54249A2EF79293001BB2ED /* PBXContainerItemProxy */;
263 +
		};
264 +
		0C5424A52EF79293001BB2ED /* PBXTargetDependency */ = {
265 +
			isa = PBXTargetDependency;
266 +
			target = 0C54248B2EF79292001BB2ED /* Gemini */;
267 +
			targetProxy = 0C5424A42EF79293001BB2ED /* PBXContainerItemProxy */;
268 +
		};
269 +
/* End PBXTargetDependency section */
270 +
271 +
/* Begin XCBuildConfiguration section */
272 +
		0C5424AB2EF79293001BB2ED /* Debug */ = {
273 +
			isa = XCBuildConfiguration;
274 +
			buildSettings = {
275 +
				ALWAYS_SEARCH_USER_PATHS = NO;
276 +
				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
277 +
				CLANG_ANALYZER_NONNULL = YES;
278 +
				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
279 +
				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
280 +
				CLANG_ENABLE_MODULES = YES;
281 +
				CLANG_ENABLE_OBJC_ARC = YES;
282 +
				CLANG_ENABLE_OBJC_WEAK = YES;
283 +
				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
284 +
				CLANG_WARN_BOOL_CONVERSION = YES;
285 +
				CLANG_WARN_COMMA = YES;
286 +
				CLANG_WARN_CONSTANT_CONVERSION = YES;
287 +
				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
288 +
				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
289 +
				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
290 +
				CLANG_WARN_EMPTY_BODY = YES;
291 +
				CLANG_WARN_ENUM_CONVERSION = YES;
292 +
				CLANG_WARN_INFINITE_RECURSION = YES;
293 +
				CLANG_WARN_INT_CONVERSION = YES;
294 +
				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
295 +
				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
296 +
				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
297 +
				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
298 +
				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
299 +
				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
300 +
				CLANG_WARN_STRICT_PROTOTYPES = YES;
301 +
				CLANG_WARN_SUSPICIOUS_MOVE = YES;
302 +
				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
303 +
				CLANG_WARN_UNREACHABLE_CODE = YES;
304 +
				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
305 +
				COPY_PHASE_STRIP = NO;
306 +
				DEBUG_INFORMATION_FORMAT = dwarf;
307 +
				DEVELOPMENT_TEAM = W8QNM2N67P;
308 +
				ENABLE_STRICT_OBJC_MSGSEND = YES;
309 +
				ENABLE_TESTABILITY = YES;
310 +
				ENABLE_USER_SCRIPT_SANDBOXING = YES;
311 +
				GCC_C_LANGUAGE_STANDARD = gnu17;
312 +
				GCC_DYNAMIC_NO_PIC = NO;
313 +
				GCC_NO_COMMON_BLOCKS = YES;
314 +
				GCC_OPTIMIZATION_LEVEL = 0;
315 +
				GCC_PREPROCESSOR_DEFINITIONS = (
316 +
					"DEBUG=1",
317 +
					"$(inherited)",
318 +
				);
319 +
				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
320 +
				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
321 +
				GCC_WARN_UNDECLARED_SELECTOR = YES;
322 +
				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
323 +
				GCC_WARN_UNUSED_FUNCTION = YES;
324 +
				GCC_WARN_UNUSED_VARIABLE = YES;
325 +
				IPHONEOS_DEPLOYMENT_TARGET = 26.1;
326 +
				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
327 +
				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
328 +
				MTL_FAST_MATH = YES;
329 +
				ONLY_ACTIVE_ARCH = YES;
330 +
				SDKROOT = iphoneos;
331 +
				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
332 +
				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
333 +
			};
334 +
			name = Debug;
335 +
		};
336 +
		0C5424AC2EF79293001BB2ED /* Release */ = {
337 +
			isa = XCBuildConfiguration;
338 +
			buildSettings = {
339 +
				ALWAYS_SEARCH_USER_PATHS = NO;
340 +
				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
341 +
				CLANG_ANALYZER_NONNULL = YES;
342 +
				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
343 +
				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
344 +
				CLANG_ENABLE_MODULES = YES;
345 +
				CLANG_ENABLE_OBJC_ARC = YES;
346 +
				CLANG_ENABLE_OBJC_WEAK = YES;
347 +
				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
348 +
				CLANG_WARN_BOOL_CONVERSION = YES;
349 +
				CLANG_WARN_COMMA = YES;
350 +
				CLANG_WARN_CONSTANT_CONVERSION = YES;
351 +
				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
352 +
				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
353 +
				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
354 +
				CLANG_WARN_EMPTY_BODY = YES;
355 +
				CLANG_WARN_ENUM_CONVERSION = YES;
356 +
				CLANG_WARN_INFINITE_RECURSION = YES;
357 +
				CLANG_WARN_INT_CONVERSION = YES;
358 +
				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
359 +
				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
360 +
				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
361 +
				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
362 +
				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
363 +
				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
364 +
				CLANG_WARN_STRICT_PROTOTYPES = YES;
365 +
				CLANG_WARN_SUSPICIOUS_MOVE = YES;
366 +
				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
367 +
				CLANG_WARN_UNREACHABLE_CODE = YES;
368 +
				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
369 +
				COPY_PHASE_STRIP = NO;
370 +
				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
371 +
				DEVELOPMENT_TEAM = W8QNM2N67P;
372 +
				ENABLE_NS_ASSERTIONS = NO;
373 +
				ENABLE_STRICT_OBJC_MSGSEND = YES;
374 +
				ENABLE_USER_SCRIPT_SANDBOXING = YES;
375 +
				GCC_C_LANGUAGE_STANDARD = gnu17;
376 +
				GCC_NO_COMMON_BLOCKS = YES;
377 +
				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
378 +
				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
379 +
				GCC_WARN_UNDECLARED_SELECTOR = YES;
380 +
				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
381 +
				GCC_WARN_UNUSED_FUNCTION = YES;
382 +
				GCC_WARN_UNUSED_VARIABLE = YES;
383 +
				IPHONEOS_DEPLOYMENT_TARGET = 26.1;
384 +
				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
385 +
				MTL_ENABLE_DEBUG_INFO = NO;
386 +
				MTL_FAST_MATH = YES;
387 +
				SDKROOT = iphoneos;
388 +
				SWIFT_COMPILATION_MODE = wholemodule;
389 +
				VALIDATE_PRODUCT = YES;
390 +
			};
391 +
			name = Release;
392 +
		};
393 +
		0C5424AE2EF79293001BB2ED /* Debug */ = {
394 +
			isa = XCBuildConfiguration;
395 +
			buildSettings = {
396 +
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
397 +
				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
398 +
				CODE_SIGN_STYLE = Automatic;
399 +
				CURRENT_PROJECT_VERSION = 1;
400 +
				DEVELOPMENT_TEAM = W8QNM2N67P;
401 +
				ENABLE_PREVIEWS = YES;
402 +
				GENERATE_INFOPLIST_FILE = YES;
403 +
				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
404 +
				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
405 +
				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
406 +
				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
407 +
				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
408 +
				LD_RUNPATH_SEARCH_PATHS = (
409 +
					"$(inherited)",
410 +
					"@executable_path/Frameworks",
411 +
				);
412 +
				MARKETING_VERSION = 1.0;
413 +
				PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.Gemini;
414 +
				PRODUCT_NAME = "$(TARGET_NAME)";
415 +
				STRING_CATALOG_GENERATE_SYMBOLS = YES;
416 +
				SWIFT_APPROACHABLE_CONCURRENCY = YES;
417 +
				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
418 +
				SWIFT_EMIT_LOC_STRINGS = YES;
419 +
				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
420 +
				SWIFT_VERSION = 5.0;
421 +
				TARGETED_DEVICE_FAMILY = "1,2";
422 +
			};
423 +
			name = Debug;
424 +
		};
425 +
		0C5424AF2EF79293001BB2ED /* Release */ = {
426 +
			isa = XCBuildConfiguration;
427 +
			buildSettings = {
428 +
				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
429 +
				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
430 +
				CODE_SIGN_STYLE = Automatic;
431 +
				CURRENT_PROJECT_VERSION = 1;
432 +
				DEVELOPMENT_TEAM = W8QNM2N67P;
433 +
				ENABLE_PREVIEWS = YES;
434 +
				GENERATE_INFOPLIST_FILE = YES;
435 +
				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
436 +
				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
437 +
				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
438 +
				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
439 +
				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
440 +
				LD_RUNPATH_SEARCH_PATHS = (
441 +
					"$(inherited)",
442 +
					"@executable_path/Frameworks",
443 +
				);
444 +
				MARKETING_VERSION = 1.0;
445 +
				PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.Gemini;
446 +
				PRODUCT_NAME = "$(TARGET_NAME)";
447 +
				STRING_CATALOG_GENERATE_SYMBOLS = YES;
448 +
				SWIFT_APPROACHABLE_CONCURRENCY = YES;
449 +
				SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
450 +
				SWIFT_EMIT_LOC_STRINGS = YES;
451 +
				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
452 +
				SWIFT_VERSION = 5.0;
453 +
				TARGETED_DEVICE_FAMILY = "1,2";
454 +
			};
455 +
			name = Release;
456 +
		};
457 +
		0C5424B12EF79293001BB2ED /* Debug */ = {
458 +
			isa = XCBuildConfiguration;
459 +
			buildSettings = {
460 +
				BUNDLE_LOADER = "$(TEST_HOST)";
461 +
				CODE_SIGN_STYLE = Automatic;
462 +
				CURRENT_PROJECT_VERSION = 1;
463 +
				DEVELOPMENT_TEAM = W8QNM2N67P;
464 +
				GENERATE_INFOPLIST_FILE = YES;
465 +
				IPHONEOS_DEPLOYMENT_TARGET = 26.1;
466 +
				MARKETING_VERSION = 1.0;
467 +
				PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.GeminiTests;
468 +
				PRODUCT_NAME = "$(TARGET_NAME)";
469 +
				STRING_CATALOG_GENERATE_SYMBOLS = NO;
470 +
				SWIFT_APPROACHABLE_CONCURRENCY = YES;
471 +
				SWIFT_EMIT_LOC_STRINGS = NO;
472 +
				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
473 +
				SWIFT_VERSION = 5.0;
474 +
				TARGETED_DEVICE_FAMILY = "1,2";
475 +
				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gemini.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Gemini";
476 +
			};
477 +
			name = Debug;
478 +
		};
479 +
		0C5424B22EF79293001BB2ED /* Release */ = {
480 +
			isa = XCBuildConfiguration;
481 +
			buildSettings = {
482 +
				BUNDLE_LOADER = "$(TEST_HOST)";
483 +
				CODE_SIGN_STYLE = Automatic;
484 +
				CURRENT_PROJECT_VERSION = 1;
485 +
				DEVELOPMENT_TEAM = W8QNM2N67P;
486 +
				GENERATE_INFOPLIST_FILE = YES;
487 +
				IPHONEOS_DEPLOYMENT_TARGET = 26.1;
488 +
				MARKETING_VERSION = 1.0;
489 +
				PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.GeminiTests;
490 +
				PRODUCT_NAME = "$(TARGET_NAME)";
491 +
				STRING_CATALOG_GENERATE_SYMBOLS = NO;
492 +
				SWIFT_APPROACHABLE_CONCURRENCY = YES;
493 +
				SWIFT_EMIT_LOC_STRINGS = NO;
494 +
				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
495 +
				SWIFT_VERSION = 5.0;
496 +
				TARGETED_DEVICE_FAMILY = "1,2";
497 +
				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Gemini.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Gemini";
498 +
			};
499 +
			name = Release;
500 +
		};
501 +
		0C5424B42EF79293001BB2ED /* Debug */ = {
502 +
			isa = XCBuildConfiguration;
503 +
			buildSettings = {
504 +
				CODE_SIGN_STYLE = Automatic;
505 +
				CURRENT_PROJECT_VERSION = 1;
506 +
				DEVELOPMENT_TEAM = W8QNM2N67P;
507 +
				GENERATE_INFOPLIST_FILE = YES;
508 +
				MARKETING_VERSION = 1.0;
509 +
				PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.GeminiUITests;
510 +
				PRODUCT_NAME = "$(TARGET_NAME)";
511 +
				STRING_CATALOG_GENERATE_SYMBOLS = NO;
512 +
				SWIFT_APPROACHABLE_CONCURRENCY = YES;
513 +
				SWIFT_EMIT_LOC_STRINGS = NO;
514 +
				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
515 +
				SWIFT_VERSION = 5.0;
516 +
				TARGETED_DEVICE_FAMILY = "1,2";
517 +
				TEST_TARGET_NAME = Gemini;
518 +
			};
519 +
			name = Debug;
520 +
		};
521 +
		0C5424B52EF79293001BB2ED /* Release */ = {
522 +
			isa = XCBuildConfiguration;
523 +
			buildSettings = {
524 +
				CODE_SIGN_STYLE = Automatic;
525 +
				CURRENT_PROJECT_VERSION = 1;
526 +
				DEVELOPMENT_TEAM = W8QNM2N67P;
527 +
				GENERATE_INFOPLIST_FILE = YES;
528 +
				MARKETING_VERSION = 1.0;
529 +
				PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.GeminiUITests;
530 +
				PRODUCT_NAME = "$(TARGET_NAME)";
531 +
				STRING_CATALOG_GENERATE_SYMBOLS = NO;
532 +
				SWIFT_APPROACHABLE_CONCURRENCY = YES;
533 +
				SWIFT_EMIT_LOC_STRINGS = NO;
534 +
				SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
535 +
				SWIFT_VERSION = 5.0;
536 +
				TARGETED_DEVICE_FAMILY = "1,2";
537 +
				TEST_TARGET_NAME = Gemini;
538 +
			};
539 +
			name = Release;
540 +
		};
541 +
/* End XCBuildConfiguration section */
542 +
543 +
/* Begin XCConfigurationList section */
544 +
		0C5424872EF79292001BB2ED /* Build configuration list for PBXProject "Gemini" */ = {
545 +
			isa = XCConfigurationList;
546 +
			buildConfigurations = (
547 +
				0C5424AB2EF79293001BB2ED /* Debug */,
548 +
				0C5424AC2EF79293001BB2ED /* Release */,
549 +
			);
550 +
			defaultConfigurationIsVisible = 0;
551 +
			defaultConfigurationName = Release;
552 +
		};
553 +
		0C5424AD2EF79293001BB2ED /* Build configuration list for PBXNativeTarget "Gemini" */ = {
554 +
			isa = XCConfigurationList;
555 +
			buildConfigurations = (
556 +
				0C5424AE2EF79293001BB2ED /* Debug */,
557 +
				0C5424AF2EF79293001BB2ED /* Release */,
558 +
			);
559 +
			defaultConfigurationIsVisible = 0;
560 +
			defaultConfigurationName = Release;
561 +
		};
562 +
		0C5424B02EF79293001BB2ED /* Build configuration list for PBXNativeTarget "GeminiTests" */ = {
563 +
			isa = XCConfigurationList;
564 +
			buildConfigurations = (
565 +
				0C5424B12EF79293001BB2ED /* Debug */,
566 +
				0C5424B22EF79293001BB2ED /* Release */,
567 +
			);
568 +
			defaultConfigurationIsVisible = 0;
569 +
			defaultConfigurationName = Release;
570 +
		};
571 +
		0C5424B32EF79293001BB2ED /* Build configuration list for PBXNativeTarget "GeminiUITests" */ = {
572 +
			isa = XCConfigurationList;
573 +
			buildConfigurations = (
574 +
				0C5424B42EF79293001BB2ED /* Debug */,
575 +
				0C5424B52EF79293001BB2ED /* Release */,
576 +
			);
577 +
			defaultConfigurationIsVisible = 0;
578 +
			defaultConfigurationName = Release;
579 +
		};
580 +
/* End XCConfigurationList section */
581 +
	};
582 +
	rootObject = 0C5424842EF79292001BB2ED /* Project object */;
583 +
}
Gemini.xcodeproj/project.xcworkspace/contents.xcworkspacedata (added) +7 −0
1 +
<?xml version="1.0" encoding="UTF-8"?>
2 +
<Workspace
3 +
   version = "1.0">
4 +
   <FileRef
5 +
      location = "self:">
6 +
   </FileRef>
7 +
</Workspace>
Gemini.xcodeproj/xcuserdata/stevedylandev.xcuserdatad/xcschemes/xcschememanagement.plist (added) +14 −0
1 +
<?xml version="1.0" encoding="UTF-8"?>
2 +
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +
<plist version="1.0">
4 +
<dict>
5 +
	<key>SchemeUserState</key>
6 +
	<dict>
7 +
		<key>Gemini.xcscheme_^#shared#^_</key>
8 +
		<dict>
9 +
			<key>orderHint</key>
10 +
			<integer>0</integer>
11 +
		</dict>
12 +
	</dict>
13 +
</dict>
14 +
</plist>
Gemini/Assets.xcassets/AccentColor.colorset/Contents.json (added) +11 −0
1 +
{
2 +
  "colors" : [
3 +
    {
4 +
      "idiom" : "universal"
5 +
    }
6 +
  ],
7 +
  "info" : {
8 +
    "author" : "xcode",
9 +
    "version" : 1
10 +
  }
11 +
}
Gemini/Assets.xcassets/AppIcon.appiconset/Contents.json (added) +35 −0
1 +
{
2 +
  "images" : [
3 +
    {
4 +
      "idiom" : "universal",
5 +
      "platform" : "ios",
6 +
      "size" : "1024x1024"
7 +
    },
8 +
    {
9 +
      "appearances" : [
10 +
        {
11 +
          "appearance" : "luminosity",
12 +
          "value" : "dark"
13 +
        }
14 +
      ],
15 +
      "idiom" : "universal",
16 +
      "platform" : "ios",
17 +
      "size" : "1024x1024"
18 +
    },
19 +
    {
20 +
      "appearances" : [
21 +
        {
22 +
          "appearance" : "luminosity",
23 +
          "value" : "tinted"
24 +
        }
25 +
      ],
26 +
      "idiom" : "universal",
27 +
      "platform" : "ios",
28 +
      "size" : "1024x1024"
29 +
    }
30 +
  ],
31 +
  "info" : {
32 +
    "author" : "xcode",
33 +
    "version" : 1
34 +
  }
35 +
}
Gemini/Assets.xcassets/Contents.json (added) +6 −0
1 +
{
2 +
  "info" : {
3 +
    "author" : "xcode",
4 +
    "version" : 1
5 +
  }
6 +
}
Gemini/ContentView.swift (added) +391 −0
1 +
//
2 +
//  ContentView.swift
3 +
//  Gemini
4 +
//
5 +
//  Created by Steve Simkins on 12/20/25.
6 +
//
7 +
8 +
// ContentView.swift (or your main view file)
9 +
import SwiftUI
10 +
11 +
// MARK: - Gemini Content Parser
12 +
13 +
enum GeminiLine {
14 +
    case text(String)
15 +
    case link(url: String, label: String)
16 +
    case heading1(String)
17 +
    case heading2(String)
18 +
    case heading3(String)
19 +
    case listItem(String)
20 +
    case quote(String)
21 +
    case preformattedToggle(alt: String)
22 +
    case preformatted(String)
23 +
}
24 +
25 +
struct GeminiParser {
26 +
    static func parse(_ content: String, baseURL: String) -> [GeminiLine] {
27 +
        var lines: [GeminiLine] = []
28 +
        var inPreformatted = false
29 +
30 +
        for line in content.components(separatedBy: .newlines) {
31 +
            if line.hasPrefix("```") {
32 +
                inPreformatted.toggle()
33 +
                let alt = String(line.dropFirst(3))
34 +
                lines.append(.preformattedToggle(alt: alt))
35 +
                continue
36 +
            }
37 +
38 +
            if inPreformatted {
39 +
                lines.append(.preformatted(line))
40 +
                continue
41 +
            }
42 +
43 +
            if line.hasPrefix("###") {
44 +
                lines.append(.heading3(String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)))
45 +
            } else if line.hasPrefix("##") {
46 +
                lines.append(.heading2(String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces)))
47 +
            } else if line.hasPrefix("#") {
48 +
                lines.append(.heading1(String(line.dropFirst(1)).trimmingCharacters(in: .whitespaces)))
49 +
            } else if line.hasPrefix("=>") {
50 +
                let linkContent = String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces)
51 +
                let (url, label) = parseLink(linkContent, baseURL: baseURL)
52 +
                lines.append(.link(url: url, label: label))
53 +
            } else if line.hasPrefix("* ") {
54 +
                lines.append(.listItem(String(line.dropFirst(2))))
55 +
            } else if line.hasPrefix(">") {
56 +
                lines.append(.quote(String(line.dropFirst(1))))
57 +
            } else {
58 +
                lines.append(.text(line))
59 +
            }
60 +
        }
61 +
62 +
        return lines
63 +
    }
64 +
65 +
    private static func parseLink(_ content: String, baseURL: String) -> (url: String, label: String) {
66 +
        // Split on any whitespace (spaces, tabs, etc.)
67 +
        let trimmed = content.trimmingCharacters(in: .whitespaces)
68 +
        let components = trimmed.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
69 +
70 +
        let rawURL = components.first ?? ""
71 +
        let label = components.count > 1 ? components.dropFirst().joined(separator: " ") : rawURL
72 +
73 +
        // Resolve relative URLs
74 +
        let resolvedURL: String
75 +
        if rawURL.contains("://") {
76 +
            resolvedURL = rawURL
77 +
        } else if let base = URL(string: baseURL),
78 +
                  let resolved = URL(string: rawURL, relativeTo: base) {
79 +
            resolvedURL = resolved.absoluteString
80 +
        } else {
81 +
            resolvedURL = rawURL
82 +
        }
83 +
84 +
        return (resolvedURL, label)
85 +
    }
86 +
}
87 +
88 +
// MARK: - Gemini Content View
89 +
90 +
struct GeminiContentView: View {
91 +
    let content: String
92 +
    let baseURL: String
93 +
    let onLinkTap: (String) -> Void
94 +
95 +
    init(content: String, baseURL: String = "", onLinkTap: @escaping (String) -> Void) {
96 +
        self.content = content
97 +
        self.baseURL = baseURL
98 +
        self.onLinkTap = onLinkTap
99 +
    }
100 +
101 +
    var body: some View {
102 +
        let lines = GeminiParser.parse(content, baseURL: baseURL)
103 +
104 +
        LazyVStack(alignment: .leading, spacing: 4) {
105 +
            ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
106 +
                lineView(for: line)
107 +
            }
108 +
        }
109 +
        .padding(8)
110 +
    }
111 +
112 +
    @ViewBuilder
113 +
    private func lineView(for line: GeminiLine) -> some View {
114 +
        switch line {
115 +
        case .text(let text):
116 +
            Text(text)
117 +
                .font(.system(.body, design: .monospaced))
118 +
119 +
        case .link(let url, let label):
120 +
            Button(action: { onLinkTap(url) }) {
121 +
                HStack(spacing: 4) {
122 +
                    Image(systemName: "arrow.right")
123 +
                        .font(.system(.caption, design: .monospaced))
124 +
                    Text(label)
125 +
                        .multilineTextAlignment(.leading)
126 +
                        .font(.system(.caption, design: .monospaced))
127 +
                }
128 +
            }
129 +
            .foregroundColor(.blue)
130 +
131 +
        case .heading1(let text):
132 +
            Text(text)
133 +
                .font(.system(.title, design: .monospaced))
134 +
                .fontWeight(.bold)
135 +
                .padding(.top, 8)
136 +
137 +
        case .heading2(let text):
138 +
            Text(text)
139 +
                .font(.system(.title2, design: .monospaced))
140 +
                .fontWeight(.semibold)
141 +
                .padding(.top, 6)
142 +
143 +
        case .heading3(let text):
144 +
            Text(text)
145 +
                .font(.system(.title3, design: .monospaced))
146 +
                .fontWeight(.medium)
147 +
                .padding(.top, 4)
148 +
149 +
        case .listItem(let text):
150 +
            HStack(alignment: .top, spacing: 8) {
151 +
                Text("\u{2022}")
152 +
                Text(text)
153 +
            }
154 +
            .font(.system(.body, design: .monospaced))
155 +
156 +
        case .quote(let text):
157 +
            Text(text)
158 +
                .font(.system(.body, design: .monospaced))
159 +
                .italic()
160 +
                .foregroundColor(.secondary)
161 +
                .padding(.leading, 12)
162 +
163 +
        case .preformattedToggle:
164 +
            EmptyView()
165 +
166 +
        case .preformatted(let text):
167 +
            Text(text)
168 +
                .font(.system(.caption, design: .monospaced))
169 +
                .foregroundColor(.secondary)
170 +
                .padding(.leading, 8)
171 +
        }
172 +
    }
173 +
}
174 +
175 +
// MARK: - Main Content View
176 +
177 +
struct ContentView: View {
178 +
    @State private var urlText = "gemini://gemini.circumlunar.space/"
179 +
    @State private var responseText = ""
180 +
    @State private var isLoading = false
181 +
182 +
    // Input prompt state
183 +
    @State private var showInputPrompt = false
184 +
    @State private var inputPromptText = ""
185 +
    @State private var inputValue = ""
186 +
    @State private var inputIsSensitive = false
187 +
    @State private var pendingInputURL = ""
188 +
189 +
    // Navigation history
190 +
    @State private var history: [String] = []
191 +
    @State private var historyIndex = -1
192 +
193 +
    private let maxRedirects = 5
194 +
195 +
    var body: some View {
196 +
        VStack(spacing: 12) {
197 +
            ScrollView {
198 +
                GeminiContentView(content: responseText, baseURL: urlText, onLinkTap: { url in
199 +
                    navigateTo(url)
200 +
                })
201 +
                .frame(maxWidth: .infinity, alignment: .leading)
202 +
            }
203 +
204 +
            HStack(spacing: 8) {
205 +
                // Back button
206 +
                Button(action: goBack) {
207 +
                    Image(systemName: "chevron.left")
208 +
                        .font(.title2)
209 +
                }
210 +
                .disabled(!canGoBack || isLoading)
211 +
212 +
                // Forward button
213 +
                Button(action: goForward) {
214 +
                    Image(systemName: "chevron.right")
215 +
                        .font(.title2)
216 +
                }
217 +
                .disabled(!canGoForward || isLoading)
218 +
219 +
                TextField("Enter Gemini URL", text: $urlText)
220 +
                    .textFieldStyle(.roundedBorder)
221 +
                    .autocapitalization(.none)
222 +
                    .disableAutocorrection(true)
223 +
                    .keyboardType(.URL)
224 +
                    .onSubmit {
225 +
                        navigateTo(urlText)
226 +
                    }
227 +
228 +
                Button(action: { navigateTo(urlText) }) {
229 +
                    if isLoading {
230 +
                        ProgressView()
231 +
                            .progressViewStyle(CircularProgressViewStyle())
232 +
                    } else {
233 +
                        Image(systemName: "arrow.right.circle.fill")
234 +
                            .font(.title2)
235 +
                    }
236 +
                }
237 +
                .disabled(isLoading || urlText.isEmpty)
238 +
            }
239 +
        }
240 +
        .padding()
241 +
        .alert("Input Required", isPresented: $showInputPrompt) {
242 +
            if inputIsSensitive {
243 +
                SecureField("Enter input", text: $inputValue)
244 +
            } else {
245 +
                TextField("Enter input", text: $inputValue)
246 +
            }
247 +
            Button("Cancel", role: .cancel) {
248 +
                inputValue = ""
249 +
            }
250 +
            Button("Submit") {
251 +
                submitInput()
252 +
            }
253 +
        } message: {
254 +
            Text(inputPromptText)
255 +
        }
256 +
    }
257 +
258 +
    private func submitInput() {
259 +
        guard !inputValue.isEmpty else { return }
260 +
261 +
        // URL-encode the input (spaces become %20, etc.)
262 +
        let encoded = inputValue.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? inputValue
263 +
264 +
        // Append query string to URL
265 +
        let urlWithQuery = pendingInputURL + "?" + encoded
266 +
        inputValue = ""
267 +
268 +
        // Fetch with the input
269 +
        navigateTo(urlWithQuery)
270 +
    }
271 +
272 +
    // MARK: - Navigation History
273 +
274 +
    private var canGoBack: Bool {
275 +
        historyIndex > 0
276 +
    }
277 +
278 +
    private var canGoForward: Bool {
279 +
        historyIndex < history.count - 1
280 +
    }
281 +
282 +
    private func navigateTo(_ url: String) {
283 +
        // Remove forward history when navigating to new page
284 +
        if historyIndex < history.count - 1 {
285 +
            history = Array(history.prefix(historyIndex + 1))
286 +
        }
287 +
288 +
        urlText = url
289 +
        fetchContent(addToHistory: true)
290 +
    }
291 +
292 +
    private func goBack() {
293 +
        guard canGoBack else { return }
294 +
        historyIndex -= 1
295 +
        urlText = history[historyIndex]
296 +
        fetchContent(addToHistory: false)
297 +
    }
298 +
299 +
    private func goForward() {
300 +
        guard canGoForward else { return }
301 +
        historyIndex += 1
302 +
        urlText = history[historyIndex]
303 +
        fetchContent(addToHistory: false)
304 +
    }
305 +
306 +
    private func fetchContent(addToHistory: Bool = true) {
307 +
        isLoading = true
308 +
        Task {
309 +
            do {
310 +
                let (response, finalURL) = try await fetchWithRedirects(urlString: urlText, redirectCount: 0)
311 +
312 +
                // Update URL bar if we followed redirects
313 +
                if finalURL != urlText {
314 +
                    urlText = finalURL
315 +
                }
316 +
317 +
                switch response.statusCategory {
318 +
                case .success:
319 +
                    responseText = response.bodyText ?? "(empty response)"
320 +
                    // Add to history on successful navigation
321 +
                    if addToHistory {
322 +
                        history.append(finalURL)
323 +
                        historyIndex = history.count - 1
324 +
                    }
325 +
                case .input:
326 +
                    // Show input prompt
327 +
                    pendingInputURL = finalURL
328 +
                    inputPromptText = response.meta
329 +
                    inputIsSensitive = response.statusCode == 11
330 +
                    showInputPrompt = true
331 +
                case .redirect:
332 +
                    responseText = "Too many redirects"
333 +
                case .temporaryFailure:
334 +
                    responseText = "Temporary failure (\(response.statusCode)): \(response.meta)"
335 +
                case .permanentFailure:
336 +
                    responseText = "Error (\(response.statusCode)): \(response.meta)"
337 +
                case .clientCertificate:
338 +
                    responseText = "Client certificate required: \(response.meta)"
339 +
                }
340 +
            } catch {
341 +
                responseText = "Error: \(error.localizedDescription)"
342 +
            }
343 +
            isLoading = false
344 +
        }
345 +
    }
346 +
347 +
    private func fetchWithRedirects(urlString: String, redirectCount: Int) async throws -> (GeminiResponse, String) {
348 +
        guard let url = URL(string: urlString),
349 +
              let host = url.host else {
350 +
            throw GeminiError.invalidURL
351 +
        }
352 +
353 +
        let client = GeminiClient(rejectUnauthorized: false)
354 +
        let port = url.port ?? 1965
355 +
        let response = try await client.connect(
356 +
            hostname: host,
357 +
            port: port,
358 +
            urlString: urlString
359 +
        )
360 +
361 +
        // Handle redirects
362 +
        if response.statusCategory == .redirect {
363 +
            guard redirectCount < maxRedirects else {
364 +
                return (response, urlString)
365 +
            }
366 +
367 +
            // Resolve relative URLs against current URL
368 +
            let redirectTarget: String
369 +
            if response.meta.hasPrefix("gemini://") {
370 +
                redirectTarget = response.meta
371 +
            } else {
372 +
                // Relative URL - resolve against current
373 +
                if let baseURL = URL(string: urlString),
374 +
                   let resolved = URL(string: response.meta, relativeTo: baseURL) {
375 +
                    redirectTarget = resolved.absoluteString
376 +
                } else {
377 +
                    redirectTarget = response.meta
378 +
                }
379 +
            }
380 +
381 +
            print("↪️ Redirecting to: \(redirectTarget)")
382 +
            return try await fetchWithRedirects(urlString: redirectTarget, redirectCount: redirectCount + 1)
383 +
        }
384 +
385 +
        return (response, urlString)
386 +
    }
387 +
}
388 +
389 +
#Preview {
390 +
    ContentView()
391 +
}
Gemini/GeminiApp.swift (added) +17 −0
1 +
//
2 +
//  GeminiApp.swift
3 +
//  Gemini
4 +
//
5 +
//  Created by Steve Simkins on 12/20/25.
6 +
//
7 +
8 +
import SwiftUI
9 +
10 +
@main
11 +
struct GeminiApp: App {
12 +
    var body: some Scene {
13 +
        WindowGroup {
14 +
            ContentView()
15 +
        }
16 +
    }
17 +
}
Gemini/GeminiClient.swift (added) +191 −0
1 +
//
2 +
//  GeminiClient.swift
3 +
//  Gemini
4 +
//
5 +
//  Created by Steve Simkins on 12/20/25.
6 +
//
7 +
8 +
import Network
9 +
import Foundation
10 +
11 +
// MARK: - Response Types
12 +
13 +
struct GeminiResponse {
14 +
    let statusCode: Int
15 +
    let meta: String
16 +
    let body: Data?
17 +
18 +
    var statusCategory: StatusCategory {
19 +
        StatusCategory(rawValue: statusCode / 10) ?? .permanentFailure
20 +
    }
21 +
22 +
    var bodyText: String? {
23 +
        guard let body else { return nil }
24 +
        return String(data: body, encoding: .utf8)
25 +
    }
26 +
27 +
    enum StatusCategory: Int {
28 +
        case input = 1
29 +
        case success = 2
30 +
        case redirect = 3
31 +
        case temporaryFailure = 4
32 +
        case permanentFailure = 5
33 +
        case clientCertificate = 6
34 +
    }
35 +
}
36 +
37 +
enum GeminiError: LocalizedError {
38 +
    case invalidResponse
39 +
    case invalidURL
40 +
41 +
    var errorDescription: String? {
42 +
        switch self {
43 +
        case .invalidResponse: return "Invalid response from server"
44 +
        case .invalidURL: return "Invalid URL"
45 +
        }
46 +
    }
47 +
}
48 +
49 +
// MARK: - Client
50 +
51 +
class GeminiClient {
52 +
    let rejectUnauthorized: Bool
53 +
    
54 +
    init(rejectUnauthorized: Bool = true) {
55 +
        self.rejectUnauthorized = rejectUnauthorized
56 +
    }
57 +
    
58 +
    func connect(
59 +
        hostname: String,
60 +
        port: Int = 1965,
61 +
        urlString: String
62 +
    ) async throws -> GeminiResponse {
63 +
        let host = NWEndpoint.Host(hostname)
64 +
        let port = NWEndpoint.Port(integerLiteral: UInt16(port))
65 +
        
66 +
        let tlsOptions = NWProtocolTLS.Options()
67 +
        let rejectUnauthorized = self.rejectUnauthorized  // Capture the value, not self
68 +
69 +
        sec_protocol_options_set_verify_block(
70 +
            tlsOptions.securityProtocolOptions,
71 +
            { _, trust, verify_complete in
72 +
                if rejectUnauthorized {
73 +
                    var error: CFError?
74 +
                    let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()
75 +
                    let result = SecTrustEvaluateWithError(secTrust, &error)
76 +
                    verify_complete(result)
77 +
                } else {
78 +
                    verify_complete(true)
79 +
                }
80 +
            },
81 +
            DispatchQueue.main
82 +
        )
83 +
        let parameters = NWParameters(tls: tlsOptions)
84 +
        let connection = NWConnection(host: host, port: port, using: parameters)
85 +
        let state = ConnectionState()
86 +
        
87 +
        return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<GeminiResponse, Error>) in
88 +
            connection.stateUpdateHandler = { connectionState in
89 +
                switch connectionState {
90 +
                case .ready:
91 +
                    print("✓ TLS connection established\n")
92 +
                    let request = urlString + "\r\n"
93 +
                    print("📤 Request: \(request.trimmingCharacters(in: .whitespacesAndNewlines))")
94 +
                    
95 +
                    if let requestData = request.data(using: .utf8) {
96 +
                        connection.send(content: requestData, completion: .idempotent)
97 +
                    }
98 +
                    
99 +
                    self.receiveData(connection: connection, state: state)
100 +
                    
101 +
                case .failed(let error):
102 +
                    print("❌ Error: \(error)")
103 +
                    connection.cancel()
104 +
                    if !state.continuationResumed {
105 +
                        state.continuationResumed = true
106 +
                        continuation.resume(throwing: error)
107 +
                    }
108 +
                    
109 +
                case .cancelled:
110 +
                    print("✓ Connection closed by server\n")
111 +
                    if !state.continuationResumed {
112 +
                        state.continuationResumed = true
113 +
                        do {
114 +
                            let response = try self.parseResponse(state.responseData)
115 +
                            continuation.resume(returning: response)
116 +
                        } catch {
117 +
                            continuation.resume(throwing: error)
118 +
                        }
119 +
                    }
120 +
                    
121 +
                default:
122 +
                    break
123 +
                }
124 +
            }
125 +
            
126 +
            connection.start(queue: .global())
127 +
        }
128 +
    }
129 +
    
130 +
    private func receiveData(connection: NWConnection, state: ConnectionState) {
131 +
        connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isComplete, error in
132 +
            if let data = data, !data.isEmpty {
133 +
                state.chunks.append(data)
134 +
            }
135 +
136 +
            if isComplete {
137 +
                connection.cancel()
138 +
            } else if error == nil {
139 +
                self.receiveData(connection: connection, state: state)
140 +
            } else if let error = error {
141 +
                print("❌ Receive error: \(error)")
142 +
                connection.cancel()
143 +
            }
144 +
        }
145 +
    }
146 +
147 +
    private func parseResponse(_ data: Data) throws -> GeminiResponse {
148 +
        // Find the first CRLF which separates header from body
149 +
        let crlf = Data([0x0D, 0x0A]) // \r\n
150 +
        guard let crlfRange = data.range(of: crlf) else {
151 +
            throw GeminiError.invalidResponse
152 +
        }
153 +
154 +
        let headerData = data[..<crlfRange.lowerBound]
155 +
        guard let headerString = String(data: headerData, encoding: .utf8) else {
156 +
            throw GeminiError.invalidResponse
157 +
        }
158 +
159 +
        // Parse status code (first 2 characters)
160 +
        guard headerString.count >= 2,
161 +
              let statusCode = Int(headerString.prefix(2)) else {
162 +
            throw GeminiError.invalidResponse
163 +
        }
164 +
165 +
        // Meta is everything after status code and space
166 +
        let meta: String
167 +
        if headerString.count > 3 {
168 +
            meta = String(headerString.dropFirst(3))
169 +
        } else {
170 +
            meta = ""
171 +
        }
172 +
173 +
        // Body is everything after the CRLF
174 +
        let bodyStartIndex = crlfRange.upperBound
175 +
        let body: Data? = bodyStartIndex < data.endIndex ? Data(data[bodyStartIndex...]) : nil
176 +
177 +
        print("📥 Status: \(statusCode), Meta: \(meta)")
178 +
179 +
        return GeminiResponse(statusCode: statusCode, meta: meta, body: body)
180 +
    }
181 +
    
182 +
    // Helper class to manage connection state
183 +
    private class ConnectionState {
184 +
        var chunks: [Data] = []
185 +
        var continuationResumed = false
186 +
        
187 +
        var responseData: Data {
188 +
            chunks.reduce(Data(), +)
189 +
        }
190 +
    }
191 +
}
GeminiTests/GeminiTests.swift (added) +17 −0
1 +
//
2 +
//  GeminiTests.swift
3 +
//  GeminiTests
4 +
//
5 +
//  Created by Steve Simkins on 12/20/25.
6 +
//
7 +
8 +
import Testing
9 +
@testable import Gemini
10 +
11 +
struct GeminiTests {
12 +
13 +
    @Test func example() async throws {
14 +
        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
15 +
    }
16 +
17 +
}
GeminiUITests/GeminiUITests.swift (added) +41 −0
1 +
//
2 +
//  GeminiUITests.swift
3 +
//  GeminiUITests
4 +
//
5 +
//  Created by Steve Simkins on 12/20/25.
6 +
//
7 +
8 +
import XCTest
9 +
10 +
final class GeminiUITests: XCTestCase {
11 +
12 +
    override func setUpWithError() throws {
13 +
        // Put setup code here. This method is called before the invocation of each test method in the class.
14 +
15 +
        // In UI tests it is usually best to stop immediately when a failure occurs.
16 +
        continueAfterFailure = false
17 +
18 +
        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
19 +
    }
20 +
21 +
    override func tearDownWithError() throws {
22 +
        // Put teardown code here. This method is called after the invocation of each test method in the class.
23 +
    }
24 +
25 +
    @MainActor
26 +
    func testExample() throws {
27 +
        // UI tests must launch the application that they test.
28 +
        let app = XCUIApplication()
29 +
        app.launch()
30 +
31 +
        // Use XCTAssert and related functions to verify your tests produce the correct results.
32 +
    }
33 +
34 +
    @MainActor
35 +
    func testLaunchPerformance() throws {
36 +
        // This measures how long it takes to launch your application.
37 +
        measure(metrics: [XCTApplicationLaunchMetric()]) {
38 +
            XCUIApplication().launch()
39 +
        }
40 +
    }
41 +
}
GeminiUITests/GeminiUITestsLaunchTests.swift (added) +33 −0
1 +
//
2 +
//  GeminiUITestsLaunchTests.swift
3 +
//  GeminiUITests
4 +
//
5 +
//  Created by Steve Simkins on 12/20/25.
6 +
//
7 +
8 +
import XCTest
9 +
10 +
final class GeminiUITestsLaunchTests: XCTestCase {
11 +
12 +
    override class var runsForEachTargetApplicationUIConfiguration: Bool {
13 +
        true
14 +
    }
15 +
16 +
    override func setUpWithError() throws {
17 +
        continueAfterFailure = false
18 +
    }
19 +
20 +
    @MainActor
21 +
    func testLaunch() throws {
22 +
        let app = XCUIApplication()
23 +
        app.launch()
24 +
25 +
        // Insert steps here to perform after app launch but before taking a screenshot,
26 +
        // such as logging into a test account or navigating somewhere in the app
27 +
28 +
        let attachment = XCTAttachment(screenshot: app.screenshot())
29 +
        attachment.name = "Launch Screen"
30 +
        attachment.lifetime = .keepAlways
31 +
        add(attachment)
32 +
    }
33 +
}