bring iOS to feture parity with frontend

This commit is contained in:
Peter Steinberger 2025-06-20 07:01:01 +02:00
parent fdafb0b522
commit d7dd436b2e
20 changed files with 2187 additions and 443 deletions

View file

@ -3,38 +3,70 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
10655A6075D28A2A6565A0A3 /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9F52961C0CE3C11D19B4AA /* SessionListView.swift */; };
10A85D034A743E712DC7EC9C /* SessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BADCA88F0695AF46222B3AB /* SessionService.swift */; };
252F7C50197317C1DD320C5D /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD2A3A081E2D10E2EEB45CA /* LoadingView.swift */; };
2F16BC1DC3080F71DA9CBAFF /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05B14A3AD2EAB350D390626 /* FileEntry.swift */; };
3957A92AFDDD4125A06826C5 /* CastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6F30F9F8BF1042B1062802 /* CastPlayerView.swift */; };
3B6417754B3785129AEAABD9 /* TerminalSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0223E64AC1DC79EF399917A /* TerminalSnapshot.swift */; };
45EBF8786E05DF1026766DB2 /* FontSizeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71EAA6D6962071517130743C /* FontSizeSheet.swift */; };
538B160DCA0BC2DF4752A363 /* SessionCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34681CD05E181BE71AC6A00 /* SessionCardView.swift */; };
562BA6B4D1BB3797259A29E8 /* FileBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B3691F13D288B60A420A0 /* FileBrowserView.swift */; };
671399118731954CBF63B4C0 /* ServerConfigForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8250BC491000BA2517528BC /* ServerConfigForm.swift */; };
7125A00FFE081D6A721ABF63 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A74BA890E4D1DF214A766499 /* Assets.xcassets */; };
7272E2FE084BB86440A69701 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 51D59A51D50DE7492ECEAA6F /* SwiftTerm */; };
736787173E49F49A351AA43C /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBBE21208D5FB72E5E43BC8 /* ServerConfig.swift */; };
7B665F33A07E379DA41E9291 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3F9DE217D8030C0E4BAA4F /* TerminalToolbar.swift */; };
7D5D17EE18D016FB9633A634 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D5E795FF5B9B26E1632820 /* Session.swift */; };
901CD6449111764E95CA6625 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3CFA0FABA146069CF88DF9 /* ContentView.swift */; };
A7BC95D91D6DD1A10C3E6DCC /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A35878ADF0225DB9359CB3B /* TerminalView.swift */; };
A882FB20F660EAB0650A11BD /* VibeTunnelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7195183E692F0E3F7FB6BD46 /* VibeTunnelApp.swift */; };
A99CE758ED28F123B74D6DDD /* BufferWebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C03EA6806BCDCDAB311FFD3 /* BufferWebSocketClient.swift */; };
B1F788D114A1145E96939A07 /* SessionCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C1575896A90B1A29B06863 /* SessionCreateView.swift */; };
B41BEF522D66FA483804F671 /* TerminalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490C9252E0C8919CEA6975E1 /* TerminalData.swift */; };
B611B173C0E3141B0A651056 /* TerminalHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1BD0BD408900CC6C0B1F89C /* TerminalHostingView.swift */; };
C6835CF39EAEEAA03DDBDB44 /* RecordingExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C288DF07D7B08AE2F9D629 /* RecordingExportSheet.swift */; };
E756EC021E402CB2F40ED3E5 /* CastFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6CC60E9A331AF6B07BAE2B3 /* CastFile.swift */; };
E872C6E384B4E05935EFD17F /* ConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2241BAA58600E11E79753A /* ConnectionView.swift */; };
F36C0895F603390F3CAD3DCD /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22B067C79374E10C55450BE1 /* APIClient.swift */; };
F828114315E4A151327A556B /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDE7FCB586F0B97ABA6643B /* Theme.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
216D52E392D8F0CDC6358BB8 /* VibeTunnel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VibeTunnel.app; sourceTree = BUILT_PRODUCTS_DIR; };
0BADCA88F0695AF46222B3AB /* SessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionService.swift; sourceTree = "<group>"; };
0C9F52961C0CE3C11D19B4AA /* SessionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = "<group>"; };
1A35878ADF0225DB9359CB3B /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
216D52E392D8F0CDC6358BB8 /* VibeTunnel.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = VibeTunnel.app; sourceTree = BUILT_PRODUCTS_DIR; };
22B067C79374E10C55450BE1 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
2A8B3691F13D288B60A420A0 /* FileBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileBrowserView.swift; sourceTree = "<group>"; };
2C03EA6806BCDCDAB311FFD3 /* BufferWebSocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BufferWebSocketClient.swift; sourceTree = "<group>"; };
39C1575896A90B1A29B06863 /* SessionCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCreateView.swift; sourceTree = "<group>"; };
3A3CFA0FABA146069CF88DF9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
490C9252E0C8919CEA6975E1 /* TerminalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalData.swift; sourceTree = "<group>"; };
55C288DF07D7B08AE2F9D629 /* RecordingExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingExportSheet.swift; sourceTree = "<group>"; };
64D5E795FF5B9B26E1632820 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
6C3F9DE217D8030C0E4BAA4F /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = "<group>"; };
6CDE7FCB586F0B97ABA6643B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
7195183E692F0E3F7FB6BD46 /* VibeTunnelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibeTunnelApp.swift; sourceTree = "<group>"; };
71EAA6D6962071517130743C /* FontSizeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSizeSheet.swift; sourceTree = "<group>"; };
8BBBE21208D5FB72E5E43BC8 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
A34681CD05E181BE71AC6A00 /* SessionCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCardView.swift; sourceTree = "<group>"; };
A74BA890E4D1DF214A766499 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A8250BC491000BA2517528BC /* ServerConfigForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigForm.swift; sourceTree = "<group>"; };
ABD2A3A081E2D10E2EEB45CA /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
AE2241BAA58600E11E79753A /* ConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionView.swift; sourceTree = "<group>"; };
B1BD0BD408900CC6C0B1F89C /* TerminalHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalHostingView.swift; sourceTree = "<group>"; };
BC6F30F9F8BF1042B1062802 /* CastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastPlayerView.swift; sourceTree = "<group>"; };
DB644C5EB44BF1B46E31349C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
E0223E64AC1DC79EF399917A /* TerminalSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSnapshot.swift; sourceTree = "<group>"; };
E6CC60E9A331AF6B07BAE2B3 /* CastFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastFile.swift; sourceTree = "<group>"; };
F05B14A3AD2EAB350D390626 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Resources/Info.plist,
);
target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A4640175A87A16583F0A76A8 /* VibeTunnel */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */,
);
path = VibeTunnel;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
2FD464BF4BA1F22E2EBF0847 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@ -55,6 +87,27 @@
);
sourceTree = "<group>";
};
1F5FB514206922162C034878 /* Utils */ = {
isa = PBXGroup;
children = (
6CDE7FCB586F0B97ABA6643B /* Theme.swift */,
);
path = Utils;
sourceTree = "<group>";
};
65A45DAAB11ECFC66DA7A032 /* Terminal */ = {
isa = PBXGroup;
children = (
BC6F30F9F8BF1042B1062802 /* CastPlayerView.swift */,
71EAA6D6962071517130743C /* FontSizeSheet.swift */,
55C288DF07D7B08AE2F9D629 /* RecordingExportSheet.swift */,
B1BD0BD408900CC6C0B1F89C /* TerminalHostingView.swift */,
6C3F9DE217D8030C0E4BAA4F /* TerminalToolbar.swift */,
1A35878ADF0225DB9359CB3B /* TerminalView.swift */,
);
path = Terminal;
sourceTree = "<group>";
};
7866022B46DD9C411CAEDA26 /* Products */ = {
isa = PBXGroup;
children = (
@ -63,24 +116,114 @@
name = Products;
sourceTree = "<group>";
};
86B28FA8FD27668A273B1C3E /* Services */ = {
isa = PBXGroup;
children = (
22B067C79374E10C55450BE1 /* APIClient.swift */,
2C03EA6806BCDCDAB311FFD3 /* BufferWebSocketClient.swift */,
0BADCA88F0695AF46222B3AB /* SessionService.swift */,
);
path = Services;
sourceTree = "<group>";
};
9E3F35439BD7C1A5A0F15879 /* App */ = {
isa = PBXGroup;
children = (
3A3CFA0FABA146069CF88DF9 /* ContentView.swift */,
7195183E692F0E3F7FB6BD46 /* VibeTunnelApp.swift */,
);
path = App;
sourceTree = "<group>";
};
A4640175A87A16583F0A76A8 /* VibeTunnel */ = {
isa = PBXGroup;
children = (
9E3F35439BD7C1A5A0F15879 /* App */,
FCC1EFC1E57CFF81577BF71B /* Models */,
AC551FF9D498BF578D8F8A28 /* Resources */,
86B28FA8FD27668A273B1C3E /* Services */,
1F5FB514206922162C034878 /* Utils */,
C8CD45CB08E314A8A25890CA /* Views */,
);
path = VibeTunnel;
sourceTree = "<group>";
};
AC551FF9D498BF578D8F8A28 /* Resources */ = {
isa = PBXGroup;
children = (
A74BA890E4D1DF214A766499 /* Assets.xcassets */,
DB644C5EB44BF1B46E31349C /* Info.plist */,
);
path = Resources;
sourceTree = "<group>";
};
C8CD45CB08E314A8A25890CA /* Views */ = {
isa = PBXGroup;
children = (
D36708B0030B5480E17544B5 /* Common */,
EA3CCEFC5FA2D00712C2F0B4 /* Connection */,
F6E767797FA3FEE7AEA9782C /* Sessions */,
65A45DAAB11ECFC66DA7A032 /* Terminal */,
2A8B3691F13D288B60A420A0 /* FileBrowserView.swift */,
);
path = Views;
sourceTree = "<group>";
};
D36708B0030B5480E17544B5 /* Common */ = {
isa = PBXGroup;
children = (
ABD2A3A081E2D10E2EEB45CA /* LoadingView.swift */,
);
path = Common;
sourceTree = "<group>";
};
EA3CCEFC5FA2D00712C2F0B4 /* Connection */ = {
isa = PBXGroup;
children = (
AE2241BAA58600E11E79753A /* ConnectionView.swift */,
A8250BC491000BA2517528BC /* ServerConfigForm.swift */,
);
path = Connection;
sourceTree = "<group>";
};
F6E767797FA3FEE7AEA9782C /* Sessions */ = {
isa = PBXGroup;
children = (
A34681CD05E181BE71AC6A00 /* SessionCardView.swift */,
39C1575896A90B1A29B06863 /* SessionCreateView.swift */,
0C9F52961C0CE3C11D19B4AA /* SessionListView.swift */,
);
path = Sessions;
sourceTree = "<group>";
};
FCC1EFC1E57CFF81577BF71B /* Models */ = {
isa = PBXGroup;
children = (
E6CC60E9A331AF6B07BAE2B3 /* CastFile.swift */,
F05B14A3AD2EAB350D390626 /* FileEntry.swift */,
8BBBE21208D5FB72E5E43BC8 /* ServerConfig.swift */,
64D5E795FF5B9B26E1632820 /* Session.swift */,
490C9252E0C8919CEA6975E1 /* TerminalData.swift */,
E0223E64AC1DC79EF399917A /* TerminalSnapshot.swift */,
);
path = Models;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
788687F02DFF4FCB00B22C15 /* VibeTunnel */ = {
9F82885C40F6FE67B5339EB3 /* VibeTunnel */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5D02E41CC1F088CA3F2F4A4C /* Build configuration list for PBXNativeTarget "VibeTunnel" */;
buildConfigurationList = 8D99F6108356CC0D04FBF9FD /* Build configuration list for PBXNativeTarget "VibeTunnel" */;
buildPhases = (
E978695B99EFF28BD859BBBB /* Sources */,
B4E21600DA0180F73AA29DAB /* Sources */,
1C0E69C8ECC6DD66167C8DED /* Resources */,
2FD464BF4BA1F22E2EBF0847 /* Frameworks */,
04DC09B2F6FC36DD72CA87FE /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
A4640175A87A16583F0A76A8 /* VibeTunnel */,
);
name = VibeTunnel;
packageProductDependencies = (
51D59A51D50DE7492ECEAA6F /* SwiftTerm */,
@ -92,201 +235,153 @@
/* End PBXNativeTarget section */
/* Begin PBXProject section */
0DD83B4196D66608380E46BA /* Project object */ = {
4C82CEFDA8E4EFE0B37538D7 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 2600;
LastUpgradeCheck = 1600;
TargetAttributes = {
788687F02DFF4FCB00B22C15 = {
CreatedOnToolsVersion = 16.0;
9F82885C40F6FE67B5339EB3 = {
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = F88CDE43C19B6F98C24FAE6B /* Build configuration list for PBXProject "VibeTunnel" */;
buildConfigurationList = 6D69C4974433AED85B9B8A62 /* Build configuration list for PBXProject "VibeTunnel" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
en,
);
mainGroup = 0DD83B4196D66608380E46BB;
minimizedProjectReferenceProxies = 1;
packageReferences = (
D39DDAD08E6E965F2F02ED4B /* XCRemoteSwiftPackageReference "SwiftTerm" */,
99D7F1C619326805A282313E /* XCRemoteSwiftPackageReference "SwiftTerm" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 7866022B46DD9C411CAEDA26 /* Products */;
preferredProjectObjectVersion = 54;
projectDirPath = "";
projectRoot = "";
targets = (
788687F02DFF4FCB00B22C15 /* VibeTunnel */,
9F82885C40F6FE67B5339EB3 /* VibeTunnel */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
04DC09B2F6FC36DD72CA87FE /* Resources */ = {
1C0E69C8ECC6DD66167C8DED /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7125A00FFE081D6A721ABF63 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
E978695B99EFF28BD859BBBB /* Sources */ = {
B4E21600DA0180F73AA29DAB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F36C0895F603390F3CAD3DCD /* APIClient.swift in Sources */,
A99CE758ED28F123B74D6DDD /* BufferWebSocketClient.swift in Sources */,
E756EC021E402CB2F40ED3E5 /* CastFile.swift in Sources */,
3957A92AFDDD4125A06826C5 /* CastPlayerView.swift in Sources */,
E872C6E384B4E05935EFD17F /* ConnectionView.swift in Sources */,
901CD6449111764E95CA6625 /* ContentView.swift in Sources */,
562BA6B4D1BB3797259A29E8 /* FileBrowserView.swift in Sources */,
2F16BC1DC3080F71DA9CBAFF /* FileEntry.swift in Sources */,
45EBF8786E05DF1026766DB2 /* FontSizeSheet.swift in Sources */,
252F7C50197317C1DD320C5D /* LoadingView.swift in Sources */,
C6835CF39EAEEAA03DDBDB44 /* RecordingExportSheet.swift in Sources */,
736787173E49F49A351AA43C /* ServerConfig.swift in Sources */,
671399118731954CBF63B4C0 /* ServerConfigForm.swift in Sources */,
7D5D17EE18D016FB9633A634 /* Session.swift in Sources */,
538B160DCA0BC2DF4752A363 /* SessionCardView.swift in Sources */,
B1F788D114A1145E96939A07 /* SessionCreateView.swift in Sources */,
10655A6075D28A2A6565A0A3 /* SessionListView.swift in Sources */,
10A85D034A743E712DC7EC9C /* SessionService.swift in Sources */,
B41BEF522D66FA483804F671 /* TerminalData.swift in Sources */,
B611B173C0E3141B0A651056 /* TerminalHostingView.swift in Sources */,
3B6417754B3785129AEAABD9 /* TerminalSnapshot.swift in Sources */,
7B665F33A07E379DA41E9291 /* TerminalToolbar.swift in Sources */,
A7BC95D91D6DD1A10C3E6DCC /* TerminalView.swift in Sources */,
F828114315E4A151327A556B /* Theme.swift in Sources */,
A882FB20F660EAB0650A11BD /* VibeTunnelApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
01B44B31B4E8DD38FB9D84EC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
2EB7D99F6AAB1EF82CFA5C1A /* Release */ = {
2287F1E073ABA2294F8D65E6 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
ENABLE_TESTABILITY = YES;
GCC_OPTIMIZATION_LEVEL = 0;
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = NO;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.ios;
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
6BDD461861CF6891A0D3CAAA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
ENABLE_TESTABILITY = YES;
GCC_OPTIMIZATION_LEVEL = s;
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.ios;
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
7C3A8A0F577E7AD8E7C60A66 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = NO;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
DD6BC2F25AD96D9BD4FC49E0 /* Release */ = {
E5ADA5251EC344824C852777 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
@ -312,12 +407,90 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
F5CF3F9AC6801B6BE276B81C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
@ -325,42 +498,49 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
5D02E41CC1F088CA3F2F4A4C /* Build configuration list for PBXNativeTarget "VibeTunnel" */ = {
6D69C4974433AED85B9B8A62 /* Build configuration list for PBXProject "VibeTunnel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7C3A8A0F577E7AD8E7C60A66 /* Debug */,
2EB7D99F6AAB1EF82CFA5C1A /* Release */,
E5ADA5251EC344824C852777 /* Debug */,
F5CF3F9AC6801B6BE276B81C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
defaultConfigurationName = Debug;
};
F88CDE43C19B6F98C24FAE6B /* Build configuration list for PBXProject "VibeTunnel" */ = {
8D99F6108356CC0D04FBF9FD /* Build configuration list for PBXNativeTarget "VibeTunnel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
01B44B31B4E8DD38FB9D84EC /* Debug */,
DD6BC2F25AD96D9BD4FC49E0 /* Release */,
2287F1E073ABA2294F8D65E6 /* Debug */,
6BDD461861CF6891A0D3CAAA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
D39DDAD08E6E965F2F02ED4B /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
99D7F1C619326805A282313E /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm";
requirement = {
@ -373,10 +553,10 @@
/* Begin XCSwiftPackageProductDependency section */
51D59A51D50DE7492ECEAA6F /* SwiftTerm */ = {
isa = XCSwiftPackageProductDependency;
package = D39DDAD08E6E965F2F02ED4B /* XCRemoteSwiftPackageReference "SwiftTerm" */;
package = 99D7F1C619326805A282313E /* XCRemoteSwiftPackageReference "SwiftTerm" */;
productName = SwiftTerm;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 0DD83B4196D66608380E46BA /* Project object */;
rootObject = 4C82CEFDA8E4EFE0B37538D7 /* Project object */;
}

View file

@ -1,7 +1,11 @@
import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View {
@EnvironmentObject var connectionManager: ConnectionManager
@State private var showingFilePicker = false
@State private var showingCastPlayer = false
@State private var selectedCastFile: URL?
var body: some View {
Group {
@ -12,5 +16,17 @@ struct ContentView: View {
}
}
.animation(.default, value: connectionManager.isConnected)
.onOpenURL { url in
// Handle cast file opening
if url.pathExtension == "cast" {
selectedCastFile = url
showingCastPlayer = true
}
}
.sheet(isPresented: $showingCastPlayer) {
if let castFile = selectedCastFile {
CastPlayerView(castFileURL: castFile)
}
}
}
}

View file

@ -3,11 +3,27 @@ import SwiftUI
@main
struct VibeTunnelApp: App {
@StateObject private var connectionManager = ConnectionManager()
@StateObject private var navigationManager = NavigationManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(connectionManager)
.environmentObject(navigationManager)
.onOpenURL { url in
handleURL(url)
}
}
}
private func handleURL(_ url: URL) {
// Handle vibetunnel://session/{sessionId} URLs
guard url.scheme == "vibetunnel" else { return }
if url.host == "session",
let sessionId = url.pathComponents.last,
!sessionId.isEmpty {
navigationManager.navigateToSession(sessionId)
}
}
}
@ -37,4 +53,19 @@ class ConnectionManager: ObservableObject {
func disconnect() {
isConnected = false
}
}
class NavigationManager: ObservableObject {
@Published var selectedSessionId: String?
@Published var shouldNavigateToSession: Bool = false
func navigateToSession(_ sessionId: String) {
selectedSessionId = sessionId
shouldNavigateToSession = true
}
func clearNavigation() {
selectedSessionId = nil
shouldNavigateToSession = false
}
}

View file

@ -0,0 +1,190 @@
import Foundation
// Asciinema cast v2 format support
struct CastFile: Codable {
let version: Int
let width: Int
let height: Int
let timestamp: TimeInterval?
let title: String?
let env: [String: String]?
let theme: CastTheme?
struct CastTheme: Codable {
let fg: String?
let bg: String?
let palette: String?
}
}
struct CastEvent: Codable {
let time: TimeInterval
let type: String
let data: String
}
// Cast file recorder for terminal sessions
@MainActor
class CastRecorder: ObservableObject {
@Published var isRecording = false
@Published var recordingStartTime: Date?
@Published var events: [CastEvent] = []
private let sessionId: String
private let width: Int
private let height: Int
private var startTime: TimeInterval = 0
init(sessionId: String, width: Int = 80, height: Int = 24) {
self.sessionId = sessionId
self.width = width
self.height = height
}
func startRecording() {
guard !isRecording else { return }
isRecording = true
recordingStartTime = Date()
startTime = Date().timeIntervalSince1970
events.removeAll()
}
func stopRecording() {
guard isRecording else { return }
isRecording = false
recordingStartTime = nil
}
func recordOutput(_ data: String) {
guard isRecording else { return }
let currentTime = Date().timeIntervalSince1970
let relativeTime = currentTime - startTime
let event = CastEvent(
time: relativeTime,
type: "o", // output
data: data
)
events.append(event)
}
func recordResize(cols: Int, rows: Int) {
guard isRecording else { return }
let currentTime = Date().timeIntervalSince1970
let relativeTime = currentTime - startTime
let resizeData = "\(cols)x\(rows)"
let event = CastEvent(
time: relativeTime,
type: "r", // resize
data: resizeData
)
events.append(event)
}
func exportCastFile() -> Data? {
// Create header
let header = CastFile(
version: 2,
width: width,
height: height,
timestamp: startTime,
title: "VibeTunnel Recording - \(sessionId)",
env: ["TERM": "xterm-256color", "SHELL": "/bin/zsh"],
theme: nil
)
guard let headerData = try? JSONEncoder().encode(header),
let headerString = String(data: headerData, encoding: .utf8) else {
return nil
}
// Build the cast file content
var castContent = headerString + "\n"
// Add all events
for event in events {
// Cast events are encoded as arrays [time, type, data]
let eventArray: [Any] = [event.time, event.type, event.data]
if let jsonData = try? JSONSerialization.data(withJSONObject: eventArray),
let jsonString = String(data: jsonData, encoding: .utf8) {
castContent += jsonString + "\n"
}
}
return castContent.data(using: .utf8)
}
}
// Cast file player for imported recordings
class CastPlayer {
let header: CastFile
let events: [CastEvent]
init?(data: Data) {
guard let content = String(data: data, encoding: .utf8) else {
return nil
}
let lines = content.components(separatedBy: .newlines)
guard !lines.isEmpty else { return nil }
// Parse header (first line)
guard let headerData = lines[0].data(using: .utf8),
let header = try? JSONDecoder().decode(CastFile.self, from: headerData) else {
return nil
}
// Parse events (remaining lines)
var parsedEvents: [CastEvent] = []
for i in 1..<lines.count {
let line = lines[i].trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty,
let lineData = line.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: lineData) as? [Any],
array.count >= 3,
let time = array[0] as? Double,
let type = array[1] as? String,
let data = array[2] as? String else {
continue
}
let event = CastEvent(time: time, type: type, data: data)
parsedEvents.append(event)
}
self.header = header
self.events = parsedEvents
}
var duration: TimeInterval {
events.last?.time ?? 0
}
func play(onEvent: @escaping @Sendable (CastEvent) -> Void, completion: @escaping @Sendable () -> Void) {
let eventsToPlay = self.events
Task { @Sendable in
for event in eventsToPlay {
// Wait for the appropriate time
if event.time > 0 {
try? await Task.sleep(nanoseconds: UInt64(event.time * 1_000_000_000))
}
await MainActor.run {
onEvent(event)
}
}
await MainActor.run {
completion()
}
}
}
}

View file

@ -4,6 +4,7 @@ struct ServerConfig: Codable, Equatable {
let host: String
let port: Int
let name: String?
let password: String?
var baseURL: URL {
URL(string: "http://\(host):\(port)")!
@ -12,4 +13,16 @@ struct ServerConfig: Codable, Equatable {
var displayName: String {
name ?? "\(host):\(port)"
}
var requiresAuthentication: Bool {
password != nil && !password!.isEmpty
}
var authorizationHeader: String? {
guard let password = password, !password.isEmpty else { return nil }
let credentials = "admin:\(password)"
guard let data = credentials.data(using: .utf8) else { return nil }
let base64 = data.base64EncodedString()
return "Basic \(base64)"
}
}

View file

@ -54,6 +54,8 @@ struct AsciinemaHeader: Codable {
let width: Int
let height: Int
let timestamp: Double?
let command: String?
let title: String?
let env: [String: String]?
}

View file

@ -0,0 +1,54 @@
import Foundation
struct TerminalSnapshot: Codable {
let sessionId: String
let header: AsciinemaHeader?
let events: [AsciinemaEvent]
enum CodingKeys: String, CodingKey {
case sessionId = "session_id"
case header
case events
}
}
struct AsciinemaEvent: Codable {
let time: Double
let type: EventType
let data: String
enum EventType: String, Codable {
case output = "o"
case input = "i"
case resize = "r"
case marker = "m"
}
}
extension TerminalSnapshot {
// Get the last few lines of terminal output for preview
var outputPreview: String {
// Combine all output events
let outputEvents = events.filter { $0.type == .output }
let combinedOutput = outputEvents.map { $0.data }.joined()
// Split into lines and get last few non-empty lines
let lines = combinedOutput.components(separatedBy: .newlines)
let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
// Take last 3-5 lines for preview
let previewLines = Array(nonEmptyLines.suffix(4))
return previewLines.joined(separator: "\n")
}
// Get a cleaned version without ANSI escape codes (basic implementation)
var cleanOutputPreview: String {
let output = outputPreview
// Remove common ANSI escape sequences (this is a simplified version)
let pattern = "\\x1B\\[[0-9;]*[mGKHf]"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(location: 0, length: output.utf16.count)
let cleaned = regex?.stringByReplacingMatches(in: output, options: [], range: range, withTemplate: "") ?? output
return cleaned
}
}

View file

@ -2,73 +2,120 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>VibeTunnel</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>TerminalBackground</string>
<key>UIImageName</key>
<string>LaunchIcon</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIAppFonts</key>
<array>
<string>FiraCode-Regular.ttf</string>
<string>FiraCode-Medium.ttf</string>
<string>FiraCode-Bold.ttf</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleLightContent</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleDisplayName</key>
<string>VibeTunnel</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>TerminalBackground</string>
<key>UIImageName</key>
<string>LaunchIcon</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIAppFonts</key>
<array>
<string>FiraCode-Regular.ttf</string>
<string>FiraCode-Medium.ttf</string>
<string>FiraCode-Bold.ttf</string>
</array>
<key>UIRequiresFullScreen</key>
<false/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleLightContent</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>vibetunnel</string>
</array>
<key>CFBundleURLName</key>
<string>com.vibetunnel.app</string>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Asciinema Recording</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>public.cast</string>
<string>public.plain-text</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>public.cast</string>
<key>UTTypeDescription</key>
<string>Asciinema Recording</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.plain-text</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>cast</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>
</plist>

View file

@ -123,6 +123,7 @@ class APIClient: APIClientProtocol {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
addAuthenticationIfNeeded(&request)
do {
request.httpBody = try encoder.encode(data)
@ -173,6 +174,7 @@ class APIClient: APIClientProtocol {
let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)")
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
addAuthenticationIfNeeded(&request)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
@ -186,6 +188,7 @@ class APIClient: APIClientProtocol {
let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/cleanup")
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
addAuthenticationIfNeeded(&request)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
@ -199,6 +202,7 @@ class APIClient: APIClientProtocol {
let url = baseURL.appendingPathComponent("api/cleanup-exited")
var request = URLRequest(url: url)
request.httpMethod = "POST"
addAuthenticationIfNeeded(&request)
let (data, response) = try await session.data(for: request)
try validateResponse(response)
@ -232,6 +236,7 @@ class APIClient: APIClientProtocol {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
addAuthenticationIfNeeded(&request)
let input = TerminalInput(text: text)
request.httpBody = try encoder.encode(input)
@ -249,6 +254,7 @@ class APIClient: APIClientProtocol {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
addAuthenticationIfNeeded(&request)
let resize = TerminalResize(cols: cols, rows: rows)
request.httpBody = try encoder.encode(resize)
@ -269,6 +275,23 @@ class APIClient: APIClientProtocol {
return baseURL.appendingPathComponent("api/sessions/\(sessionId)/snapshot")
}
func getSessionSnapshot(sessionId: String) async throws -> TerminalSnapshot {
guard let baseURL = baseURL else {
throw APIError.noServerConfigured
}
let url = baseURL.appendingPathComponent("api/sessions/\(sessionId)/snapshot")
let (data, response) = try await session.data(from: url)
try validateResponse(response)
do {
return try decoder.decode(TerminalSnapshot.self, from: data)
} catch {
throw APIError.decodingError(error)
}
}
// MARK: - Helpers
private func validateResponse(_ response: URLResponse) throws {
@ -342,6 +365,7 @@ class APIClient: APIClientProtocol {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
addAuthenticationIfNeeded(&request)
struct CreateDirectoryRequest: Codable {
let path: String

View file

@ -0,0 +1,340 @@
import Foundation
import Combine
// Terminal event types that match the server's output
enum TerminalWebSocketEvent {
case header(width: Int, height: Int)
case output(timestamp: Double, data: String)
case resize(timestamp: Double, dimensions: String)
case exit(code: Int)
}
enum WebSocketError: Error {
case invalidURL
case connectionFailed
case invalidData
case invalidMagicByte
}
@MainActor
class BufferWebSocketClient: NSObject {
// Magic byte for binary messages
private static let BUFFER_MAGIC_BYTE: UInt8 = 0xbf
private var webSocketTask: URLSessionWebSocketTask?
private let session = URLSession(configuration: .default)
private var subscriptions = [String: ((TerminalWebSocketEvent) -> Void)]()
private var reconnectTimer: Timer?
private var reconnectAttempts = 0
private var isConnecting = false
private var pingTimer: Timer?
// Published events
@Published private(set) var isConnected = false
@Published private(set) var connectionError: Error?
private var baseURL: URL? {
guard let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) else {
return nil
}
return serverConfig.baseURL
}
func connect() {
guard !isConnecting else { return }
guard let baseURL = baseURL else {
connectionError = WebSocketError.invalidURL
return
}
isConnecting = true
connectionError = nil
// Convert HTTP URL to WebSocket URL
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
components?.scheme = baseURL.scheme == "https" ? "wss" : "ws"
components?.path = "/buffers"
guard let wsURL = components?.url else {
connectionError = WebSocketError.invalidURL
isConnecting = false
return
}
print("[BufferWebSocket] Connecting to \(wsURL)")
// Cancel existing task if any
webSocketTask?.cancel(with: .goingAway, reason: nil)
// Create request with authentication
var request = URLRequest(url: wsURL)
// Add authentication header if needed
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config),
let authHeader = serverConfig.authorizationHeader {
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
}
// Create new WebSocket task
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
// Start receiving messages
receiveMessage()
// Send initial ping to establish connection
Task {
do {
try await sendPing()
isConnected = true
isConnecting = false
reconnectAttempts = 0
startPingTimer()
// Re-subscribe to all sessions
for sessionId in subscriptions.keys {
try await subscribe(to: sessionId)
}
} catch {
print("[BufferWebSocket] Connection failed: \(error)")
connectionError = error
isConnecting = false
scheduleReconnect()
}
}
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let message):
Task { @MainActor in
self.handleMessage(message)
self.receiveMessage() // Continue receiving
}
case .failure(let error):
print("[BufferWebSocket] Receive error: \(error)")
Task { @MainActor in
self.handleDisconnection()
}
}
}
}
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
switch message {
case .data(let data):
handleBinaryMessage(data)
case .string(let text):
handleTextMessage(text)
@unknown default:
break
}
}
private func handleTextMessage(_ text: String) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return
}
if let type = json["type"] as? String {
switch type {
case "ping":
// Respond with pong
Task {
try? await sendMessage(["type": "pong"])
}
case "error":
if let message = json["message"] as? String {
print("[BufferWebSocket] Server error: \(message)")
}
default:
print("[BufferWebSocket] Unknown message type: \(type)")
}
}
}
private func handleBinaryMessage(_ data: Data) {
guard data.count > 5 else { return }
var offset = 0
// Check magic byte
let magic = data[offset]
offset += 1
guard magic == Self.BUFFER_MAGIC_BYTE else {
print("[BufferWebSocket] Invalid magic byte: \(magic)")
return
}
// Read session ID length (4 bytes, little endian)
let sessionIdLength = data.withUnsafeBytes { bytes in
bytes.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian
}
offset += 4
// Read session ID
guard data.count >= offset + Int(sessionIdLength) else { return }
let sessionIdData = data.subdata(in: offset..<(offset + Int(sessionIdLength)))
guard let sessionId = String(data: sessionIdData, encoding: .utf8) else { return }
offset += Int(sessionIdLength)
// Remaining data is the message payload
let messageData = data.subdata(in: offset..<data.count)
// Decode terminal event
if let event = decodeTerminalEvent(from: messageData),
let handler = subscriptions[sessionId] {
handler(event)
}
}
private func decodeTerminalEvent(from data: Data) -> TerminalWebSocketEvent? {
// Decode the JSON payload from the binary message
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String {
switch type {
case "header":
if let width = json["width"] as? Int,
let height = json["height"] as? Int {
return .header(width: width, height: height)
}
case "output":
if let timestamp = json["timestamp"] as? Double,
let outputData = json["data"] as? String {
return .output(timestamp: timestamp, data: outputData)
}
case "resize":
if let timestamp = json["timestamp"] as? Double,
let dimensions = json["dimensions"] as? String {
return .resize(timestamp: timestamp, dimensions: dimensions)
}
case "exit":
let code = json["code"] as? Int ?? 0
return .exit(code: code)
default:
print("[BufferWebSocket] Unknown message type: \(type)")
}
}
} catch {
print("[BufferWebSocket] Failed to decode message: \(error)")
}
return nil
}
func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) {
subscriptions[sessionId] = handler
Task {
try? await subscribe(to: sessionId)
}
}
private func subscribe(to sessionId: String) async throws {
try await sendMessage(["type": "subscribe", "sessionId": sessionId])
}
func unsubscribe(from sessionId: String) {
subscriptions.removeValue(forKey: sessionId)
Task {
try? await sendMessage(["type": "unsubscribe", "sessionId": sessionId])
}
}
private func sendMessage(_ message: [String: Any]) async throws {
guard let webSocketTask = webSocketTask else {
throw WebSocketError.connectionFailed
}
let data = try JSONSerialization.data(withJSONObject: message)
guard let string = String(data: data, encoding: .utf8) else {
throw WebSocketError.invalidData
}
try await webSocketTask.send(.string(string))
}
private func sendPing() async throws {
try await sendMessage(["type": "ping"])
}
private func startPingTimer() {
stopPingTimer()
pingTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in
Task { [weak self] in
try? await self?.sendPing()
}
}
}
private func stopPingTimer() {
pingTimer?.invalidate()
pingTimer = nil
}
private func handleDisconnection() {
isConnected = false
webSocketTask = nil
stopPingTimer()
scheduleReconnect()
}
private func scheduleReconnect() {
guard reconnectTimer == nil else { return }
let delay = min(pow(2.0, Double(reconnectAttempts)), 30.0)
reconnectAttempts += 1
print("[BufferWebSocket] Reconnecting in \(delay)s (attempt \(reconnectAttempts))")
reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
Task { @MainActor [weak self] in
self?.reconnectTimer = nil
self?.connect()
}
}
}
func disconnect() {
reconnectTimer?.invalidate()
reconnectTimer = nil
stopPingTimer()
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
subscriptions.removeAll()
isConnected = false
}
deinit {
reconnectTimer?.invalidate()
reconnectTimer = nil
pingTimer?.invalidate()
pingTimer = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
subscriptions.removeAll()
}
}

View file

@ -1,117 +0,0 @@
import Foundation
final class SSEClient: NSObject, @unchecked Sendable {
private var eventSource: URLSessionDataTask?
private var session: URLSession?
private var streamContinuation: AsyncStream<TerminalEvent>.Continuation?
override init() {
super.init()
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = TimeInterval.infinity
configuration.timeoutIntervalForResource = TimeInterval.infinity
self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
}
func connect(to url: URL) -> AsyncStream<TerminalEvent> {
disconnect()
return AsyncStream { continuation in
self.streamContinuation = continuation
var request = URLRequest(url: url)
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
request.timeoutInterval = TimeInterval.infinity
self.eventSource = self.session?.dataTask(with: request)
self.eventSource?.resume()
continuation.onTermination = { @Sendable _ in
self.disconnect()
}
}
}
func disconnect() {
eventSource?.cancel()
eventSource = nil
streamContinuation?.finish()
streamContinuation = nil
}
private var dataBuffer = Data()
private func processSSEData(_ data: Data) {
dataBuffer.append(data)
// Process complete lines
while let newlineRange = dataBuffer.range(of: Data("\n".utf8)) {
let lineData = dataBuffer.subdata(in: 0..<newlineRange.lowerBound)
dataBuffer.removeSubrange(0..<newlineRange.upperBound)
guard let line = String(data: lineData, encoding: .utf8) else { continue }
processSSELine(line)
}
}
private func processSSELine(_ line: String) {
let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
// Skip empty lines and comments
if trimmedLine.isEmpty || trimmedLine.hasPrefix(":") {
return
}
// Handle event data
if trimmedLine.hasPrefix("data: ") {
let data = String(trimmedLine.dropFirst(6))
// Parse terminal event
if let event = TerminalEvent(from: data) {
streamContinuation?.yield(event)
}
}
// Handle special events
else if trimmedLine.hasPrefix("event: ") {
let eventType = String(trimmedLine.dropFirst(7))
handleSpecialEvent(eventType)
}
}
private func handleSpecialEvent(_ eventType: String) {
switch eventType {
case "exit", "end":
streamContinuation?.finish()
case "error":
// Could parse error data if needed
streamContinuation?.finish()
default:
break
}
}
}
// MARK: - URLSessionDataDelegate
extension SSEClient: URLSessionDataDelegate {
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
processSSEData(data)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print("SSE connection error: \(error)")
}
streamContinuation?.finish()
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
completionHandler(.cancel)
return
}
completionHandler(.allow)
}
}

View file

@ -56,6 +56,7 @@ struct ConnectionView: View {
host: $viewModel.host,
port: $viewModel.port,
name: $viewModel.name,
password: $viewModel.password,
isConnecting: viewModel.isConnecting,
errorMessage: viewModel.errorMessage,
onConnect: connectToServer
@ -94,6 +95,7 @@ class ConnectionViewModel: ObservableObject {
@Published var host: String = "127.0.0.1"
@Published var port: String = "4020"
@Published var name: String = ""
@Published var password: String = ""
@Published var isConnecting: Bool = false
@Published var errorMessage: String?
@ -103,6 +105,7 @@ class ConnectionViewModel: ObservableObject {
self.host = serverConfig.host
self.port = String(serverConfig.port)
self.name = serverConfig.name ?? ""
self.password = serverConfig.password ?? ""
}
}
@ -125,13 +128,18 @@ class ConnectionViewModel: ObservableObject {
let config = ServerConfig(
host: host,
port: portNumber,
name: name.isEmpty ? nil : name
name: name.isEmpty ? nil : name,
password: password.isEmpty ? nil : password
)
do {
// Test connection by fetching sessions
let url = config.baseURL.appendingPathComponent("api/sessions")
let (_, response) = try await URLSession.shared.data(from: url)
var request = URLRequest(url: url)
if let authHeader = config.authorizationHeader {
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
}
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {

View file

@ -4,6 +4,7 @@ struct ServerConfigForm: View {
@Binding var host: String
@Binding var port: String
@Binding var name: String
@Binding var password: String
let isConnecting: Bool
let errorMessage: String?
let onConnect: () -> Void
@ -12,7 +13,7 @@ struct ServerConfigForm: View {
@State private var recentServers: [ServerConfig] = []
enum Field {
case host, port, name
case host, port, name, password
}
var body: some View {
@ -61,6 +62,21 @@ struct ServerConfigForm: View {
TextField("My Mac", text: $name)
.textFieldStyle(TerminalTextFieldStyle())
.focused($focusedField, equals: .name)
.submitLabel(.next)
.onSubmit {
focusedField = .password
}
}
// Password Field (Optional)
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Label("Password (Optional)", systemImage: "lock")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.primaryAccent)
SecureField("Enter password if required", text: $password)
.textFieldStyle(TerminalTextFieldStyle())
.focused($focusedField, equals: .password)
.submitLabel(.done)
.onSubmit {
focusedField = nil
@ -141,6 +157,7 @@ struct ServerConfigForm: View {
host = server.host
port = String(server.port)
name = server.name ?? ""
password = server.password ?? ""
HapticFeedback.selection()
}) {
VStack(alignment: .leading, spacing: 4) {

View file

@ -7,6 +7,11 @@ struct SessionCardView: View {
let onCleanup: () -> Void
@State private var isPressed = false
@State private var terminalSnapshot: TerminalSnapshot?
@State private var isLoadingSnapshot = false
@State private var isKilling = false
@State private var opacity: Double = 1.0
@State private var scale: CGFloat = 1.0
private var displayWorkingDir: String {
// Convert absolute paths back to ~ notation for display
@ -35,9 +40,9 @@ struct SessionCardView: View {
Button(action: {
HapticFeedback.impact(.medium)
if session.isRunning {
onKill()
animateKill()
} else {
onCleanup()
animateCleanup()
}
}) {
Text(session.isRunning ? "kill" : "clean")
@ -53,37 +58,80 @@ struct SessionCardView: View {
.buttonStyle(PlainButtonStyle())
}
// Terminal content area showing command and working directory
// Terminal content area showing command and terminal output preview
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(Theme.Colors.terminalBackground)
.frame(height: 120)
.overlay(
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
if session.isRunning {
// Show command and working directory info
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("$")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.primaryAccent)
Text(session.command)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground)
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
// Show terminal output preview
ScrollView(.vertical, showsIndicators: false) {
Text(snapshot.cleanOutputPreview)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
}
.padding(Theme.Spacing.sm)
} else {
// Show command and working directory info as fallback
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Text("$")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.primaryAccent)
Text(session.command)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground)
}
Text(displayWorkingDir)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.lineLimit(1)
if isLoadingSnapshot {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
.scaleEffect(0.8)
Text("Loading output...")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
.padding(.top, Theme.Spacing.xs)
}
}
.padding(Theme.Spacing.sm)
Text(displayWorkingDir)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.lineLimit(1)
Spacer()
}
.padding(Theme.Spacing.sm)
Spacer()
} else {
Text("Session exited")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
.frame(maxWidth: .infinity, maxHeight: .infinity)
if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty {
// Show last output for exited sessions
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 4) {
Text("Session exited")
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.errorAccent)
Text(snapshot.cleanOutputPreview)
.font(Theme.Typography.terminalSystem(size: 10))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.6))
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(nil)
.multilineTextAlignment(.leading)
}
}
.padding(Theme.Spacing.sm)
} else {
Text("Session exited")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
)
@ -123,7 +171,8 @@ struct SessionCardView: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
)
.scaleEffect(isPressed ? 0.98 : 1.0)
.scaleEffect(isPressed ? 0.98 : scale)
.opacity(opacity)
}
.buttonStyle(PlainButtonStyle())
.onLongPressGesture(
@ -138,14 +187,77 @@ struct SessionCardView: View {
)
.contextMenu {
if session.isRunning {
Button(action: onKill) {
Button(action: animateKill) {
Label("Kill Session", systemImage: "stop.circle")
}
} else {
Button(action: onCleanup) {
Button(action: animateCleanup) {
Label("Clean Up", systemImage: "trash")
}
}
}
.onAppear {
loadSnapshot()
}
}
private func loadSnapshot() {
guard terminalSnapshot == nil else { return }
isLoadingSnapshot = true
Task {
do {
let snapshot = try await APIClient.shared.getSessionSnapshot(sessionId: session.id)
await MainActor.run {
self.terminalSnapshot = snapshot
self.isLoadingSnapshot = false
}
} catch {
// Silently fail - we'll just show the command/cwd fallback
await MainActor.run {
self.isLoadingSnapshot = false
}
}
}
}
private func animateKill() {
guard !isKilling else { return }
isKilling = true
// Shake animation
withAnimation(.linear(duration: 0.05).repeatCount(4, autoreverses: true)) {
scale = 0.97
}
// Fade out after shake
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.easeOut(duration: 0.3)) {
opacity = 0.5
scale = 0.95
}
onKill()
// Reset after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isKilling = false
withAnimation(.easeIn(duration: 0.2)) {
opacity = 1.0
scale = 1.0
}
}
}
}
private func animateCleanup() {
// Shrink and fade animation for cleanup
withAnimation(.easeOut(duration: 0.3)) {
scale = 0.8
opacity = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
onCleanup()
}
}
}

View file

@ -2,9 +2,11 @@ import SwiftUI
struct SessionListView: View {
@EnvironmentObject var connectionManager: ConnectionManager
@EnvironmentObject var navigationManager: NavigationManager
@StateObject private var viewModel = SessionListViewModel()
@State private var showingCreateSession = false
@State private var selectedSession: Session?
@State private var showExitedSessions = true
var body: some View {
NavigationView {
@ -79,6 +81,14 @@ struct SessionListView: View {
.navigationViewStyle(StackNavigationViewStyle())
.preferredColorScheme(.dark)
.environmentObject(connectionManager)
.onChange(of: navigationManager.shouldNavigateToSession) { shouldNavigate in
if shouldNavigate,
let sessionId = navigationManager.selectedSessionId,
let session = viewModel.sessions.first(where: { $0.id == sessionId }) {
selectedSession = session
navigationManager.clearNavigation()
}
}
}
private var emptyStateView: some View {
@ -128,13 +138,64 @@ struct SessionListView: View {
VStack(spacing: Theme.Spacing.lg) {
// Header with session count and kill all button
HStack {
Text("\(viewModel.sessions.count) Session\(viewModel.sessions.count == 1 ? "" : "s")")
.font(Theme.Typography.terminalSystem(size: 16))
.fontWeight(.medium)
.foregroundColor(Theme.Colors.terminalForeground)
let runningCount = viewModel.sessions.filter { $0.isRunning }.count
let exitedCount = viewModel.sessions.filter { !$0.isRunning }.count
HStack(spacing: Theme.Spacing.md) {
if runningCount > 0 {
HStack(spacing: 4) {
Text("Running:")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
Text("\(runningCount)")
.foregroundColor(Theme.Colors.successAccent)
.fontWeight(.semibold)
}
}
if exitedCount > 0 {
HStack(spacing: 4) {
Text("Exited:")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
Text("\(exitedCount)")
.foregroundColor(Theme.Colors.errorAccent)
.fontWeight(.semibold)
}
}
if runningCount == 0 && exitedCount == 0 {
Text("No Sessions")
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
}
.font(Theme.Typography.terminalSystem(size: 16))
Spacer()
// Toggle to show/hide exited sessions
if exitedCount > 0 {
Button(action: {
HapticFeedback.selection()
withAnimation(Theme.Animation.smooth) {
showExitedSessions.toggle()
}
}) {
HStack(spacing: 4) {
Image(systemName: showExitedSessions ? "eye.slash" : "eye")
.font(.caption)
Text(showExitedSessions ? "Hide Exited" : "Show Exited")
.font(Theme.Typography.terminalSystem(size: 12))
}
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.fill(Theme.Colors.terminalForeground.opacity(0.1))
)
}
.buttonStyle(PlainButtonStyle())
}
if viewModel.sessions.contains(where: { $0.isRunning }) {
Button(action: {
HapticFeedback.impact(.medium)
@ -170,7 +231,7 @@ struct SessionListView: View {
GridItem(.flexible(), spacing: Theme.Spacing.md)
], spacing: Theme.Spacing.md) {
// Clean up all button if there are exited sessions
if viewModel.sessions.contains(where: { !$0.isRunning }) {
if showExitedSessions && viewModel.sessions.contains(where: { !$0.isRunning }) {
Button(action: {
HapticFeedback.impact(.medium)
Task {
@ -201,7 +262,7 @@ struct SessionListView: View {
))
}
ForEach(viewModel.sessions) { session in
ForEach(viewModel.sessions.filter { showExitedSessions || $0.isRunning }) { session in
SessionCardView(session: session) {
HapticFeedback.selection()
if session.isRunning {

View file

@ -0,0 +1,374 @@
import SwiftUI
import SwiftTerm
import UniformTypeIdentifiers
struct CastPlayerView: View {
let castFileURL: URL
@Environment(\.dismiss) var dismiss
@StateObject private var viewModel = CastPlayerViewModel()
@State private var fontSize: CGFloat = 14
@State private var isPlaying = false
@State private var currentTime: TimeInterval = 0
@State private var playbackSpeed: Double = 1.0
var body: some View {
NavigationView {
ZStack {
Theme.Colors.terminalBackground
.ignoresSafeArea()
VStack(spacing: 0) {
if viewModel.isLoading {
loadingView
} else if let error = viewModel.errorMessage {
errorView(error)
} else if viewModel.player != nil {
playerContent
}
}
}
.navigationTitle("Recording Playback")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
.foregroundColor(Theme.Colors.primaryAccent)
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.preferredColorScheme(.dark)
.onAppear {
viewModel.loadCastFile(from: castFileURL)
}
}
private var loadingView: some View {
VStack(spacing: Theme.Spacing.lg) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
.scaleEffect(1.5)
Text("Loading recording...")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func errorView(_ error: String) -> some View {
VStack(spacing: Theme.Spacing.lg) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundColor(Theme.Colors.errorAccent)
Text("Failed to load recording")
.font(.headline)
.foregroundColor(Theme.Colors.terminalForeground)
Text(error)
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var playerContent: some View {
VStack(spacing: 0) {
// Terminal display
CastTerminalView(fontSize: $fontSize, viewModel: viewModel)
.background(Theme.Colors.terminalBackground)
// Playback controls
VStack(spacing: Theme.Spacing.md) {
// Progress bar
VStack(spacing: Theme.Spacing.xs) {
Slider(value: $currentTime, in: 0...viewModel.duration) { editing in
if !editing && isPlaying {
// Resume playback from new position
viewModel.seekTo(time: currentTime)
}
}
.accentColor(Theme.Colors.primaryAccent)
HStack {
Text(formatTime(currentTime))
.font(Theme.Typography.terminalSystem(size: 10))
Spacer()
Text(formatTime(viewModel.duration))
.font(Theme.Typography.terminalSystem(size: 10))
}
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
// Control buttons
HStack(spacing: Theme.Spacing.xl) {
// Speed control
Menu {
Button("0.5x") { playbackSpeed = 0.5 }
Button("1x") { playbackSpeed = 1.0 }
Button("2x") { playbackSpeed = 2.0 }
Button("4x") { playbackSpeed = 4.0 }
} label: {
Text("\(playbackSpeed, specifier: "%.1f")x")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.primaryAccent)
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(Theme.Colors.primaryAccent, lineWidth: 1)
)
}
// Play/Pause
Button(action: togglePlayback) {
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 44))
.foregroundColor(Theme.Colors.primaryAccent)
}
// Restart
Button(action: restart) {
Image(systemName: "arrow.counterclockwise")
.font(.system(size: 20))
.foregroundColor(Theme.Colors.primaryAccent)
}
}
}
.padding()
.background(Theme.Colors.cardBackground)
}
.onReceive(viewModel.$currentTime) { time in
if !viewModel.isSeeking {
currentTime = time
}
}
}
private func togglePlayback() {
if isPlaying {
viewModel.pause()
} else {
viewModel.play(speed: playbackSpeed)
}
isPlaying.toggle()
}
private func restart() {
viewModel.restart()
currentTime = 0
if isPlaying {
viewModel.play(speed: playbackSpeed)
}
}
private func formatTime(_ seconds: TimeInterval) -> String {
let minutes = Int(seconds) / 60
let remainingSeconds = Int(seconds) % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
// Simple terminal view for cast playback
struct CastTerminalView: UIViewRepresentable {
@Binding var fontSize: CGFloat
@ObservedObject var viewModel: CastPlayerViewModel
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
let terminal = SwiftTerm.TerminalView()
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
terminal.allowMouseReporting = false
// TODO: Check SwiftTerm API for link detection
// terminal.linkRecognizer = .autodetect
updateFont(terminal, size: fontSize)
// Set initial size from cast file if available
if let header = viewModel.header {
terminal.resize(cols: Int(header.width), rows: Int(header.height))
} else {
terminal.resize(cols: 80, rows: 24)
}
context.coordinator.terminal = terminal
return terminal
}
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
updateFont(terminal, size: fontSize)
}
func makeCoordinator() -> Coordinator {
Coordinator(viewModel: viewModel)
}
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
let font: UIFont
if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
font = customFont
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
font = fallbackFont
} else {
font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
terminal.font = font
}
@MainActor
class Coordinator: NSObject {
weak var terminal: SwiftTerm.TerminalView?
let viewModel: CastPlayerViewModel
init(viewModel: CastPlayerViewModel) {
self.viewModel = viewModel
super.init()
// Set up terminal output handler
viewModel.onTerminalOutput = { [weak self] data in
Task { @MainActor in
self?.terminal?.feed(text: data)
}
}
viewModel.onTerminalClear = { [weak self] in
Task { @MainActor in
// TODO: Check SwiftTerm API for clearing terminal
// For now, we'll feed a clear screen sequence
self?.terminal?.feed(text: "\u{001B}[2J\u{001B}[H")
}
}
}
}
}
@MainActor
class CastPlayerViewModel: ObservableObject {
@Published var isLoading = true
@Published var errorMessage: String?
@Published var currentTime: TimeInterval = 0
@Published var isSeeking = false
var player: CastPlayer?
var header: CastFile? { player?.header }
var duration: TimeInterval { player?.duration ?? 0 }
var onTerminalOutput: ((String) -> Void)?
var onTerminalClear: (() -> Void)?
private var playbackTask: Task<Void, Never>?
func loadCastFile(from url: URL) {
Task {
do {
let data = try Data(contentsOf: url)
guard let player = CastPlayer(data: data) else {
errorMessage = "Invalid cast file format"
isLoading = false
return
}
self.player = player
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
func play(speed: Double = 1.0) {
playbackTask?.cancel()
playbackTask = Task {
guard let player = player else { return }
player.play(from: currentTime, speed: speed) { [weak self] event in
guard let self = self else { return }
switch event.type {
case "o":
self.onTerminalOutput?(event.data)
case "r":
// Handle resize if needed
break
default:
break
}
self.currentTime = event.time
} completion: {
// Playback completed
}
}
}
func pause() {
playbackTask?.cancel()
}
func seekTo(time: TimeInterval) {
isSeeking = true
currentTime = time
// Clear terminal and replay up to the seek point
onTerminalClear?()
guard let player = player else { return }
// Replay all events up to the seek time instantly
for event in player.events where event.time <= time {
if event.type == "o" {
onTerminalOutput?(event.data)
}
}
isSeeking = false
}
func restart() {
playbackTask?.cancel()
currentTime = 0
onTerminalClear?()
}
}
// Extension to CastPlayer for playback from specific time
extension CastPlayer {
func play(from startTime: TimeInterval = 0, speed: Double = 1.0, onEvent: @escaping @Sendable (CastEvent) -> Void, completion: @escaping @Sendable () -> Void) {
let eventsToPlay = events.filter { $0.time > startTime }
Task { @Sendable in
var lastEventTime = startTime
for event in eventsToPlay {
// Calculate wait time adjusted for playback speed
let waitTime = (event.time - lastEventTime) / speed
if waitTime > 0 {
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
}
// Check if task was cancelled
if Task.isCancelled { break }
await MainActor.run {
onEvent(event)
}
lastEventTime = event.time
}
await MainActor.run {
completion()
}
}
}
}

View file

@ -0,0 +1,153 @@
import SwiftUI
import UniformTypeIdentifiers
struct RecordingExportSheet: View {
@ObservedObject var recorder: CastRecorder
let sessionName: String
@Environment(\.dismiss) var dismiss
@State private var isExporting = false
@State private var showingShareSheet = false
@State private var exportedFileURL: URL?
var body: some View {
NavigationView {
VStack(spacing: Theme.Spacing.xl) {
// Icon
ZStack {
Circle()
.fill(Theme.Colors.primaryAccent.opacity(0.1))
.frame(width: 80, height: 80)
Image(systemName: "record.circle.fill")
.font(.system(size: 40))
.foregroundColor(Theme.Colors.primaryAccent)
}
.padding(.top, Theme.Spacing.xl)
// Info
VStack(spacing: Theme.Spacing.sm) {
Text("Recording Export")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(Theme.Colors.terminalForeground)
if recorder.isRecording {
Text("Recording in progress...")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
} else if !recorder.events.isEmpty {
VStack(spacing: Theme.Spacing.xs) {
Text("\(recorder.events.count) events recorded")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
if let duration = recorder.events.last?.time {
Text("Duration: \(formatDuration(duration))")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
}
}
} else {
Text("No recording available")
.font(Theme.Typography.terminalSystem(size: 14))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
}
Spacer()
// Export button
if !recorder.events.isEmpty {
Button(action: exportRecording) {
if isExporting {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalBackground))
.scaleEffect(0.8)
} else {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "square.and.arrow.up")
Text("Export as .cast file")
}
}
}
.font(Theme.Typography.terminalSystem(size: 16))
.fontWeight(.medium)
.foregroundColor(Theme.Colors.terminalBackground)
.frame(maxWidth: .infinity)
.padding(.vertical, Theme.Spacing.md)
.background(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(Theme.Colors.primaryAccent)
)
.disabled(isExporting)
.padding(.horizontal)
}
Spacer()
}
.background(Theme.Colors.terminalBackground)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
.foregroundColor(Theme.Colors.primaryAccent)
}
}
}
.sheet(isPresented: $showingShareSheet) {
if let url = exportedFileURL {
ShareSheet(items: [url])
}
}
}
private func exportRecording() {
isExporting = true
Task {
if let castData = recorder.exportCastFile() {
// Create temporary file
let fileName = "\(sessionName.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).cast"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
do {
try castData.write(to: tempURL)
await MainActor.run {
exportedFileURL = tempURL
isExporting = false
showingShareSheet = true
}
} catch {
print("Failed to save cast file: \(error)")
await MainActor.run {
isExporting = false
}
}
} else {
await MainActor.run {
isExporting = false
}
}
}
}
private func formatDuration(_ seconds: TimeInterval) -> String {
let minutes = Int(seconds) / 60
let remainingSeconds = Int(seconds) % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View file

@ -7,6 +7,7 @@ struct TerminalHostingView: UIViewRepresentable {
let onInput: (String) -> Void
let onResize: (Int, Int) -> Void
@ObservedObject var viewModel: TerminalViewModel
@State private var isAutoScrollEnabled = true
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
let terminal = SwiftTerm.TerminalView()
@ -24,6 +25,10 @@ struct TerminalHostingView: UIViewRepresentable {
terminal.allowMouseReporting = false
terminal.optionAsMetaKey = true
// Enable URL detection
// TODO: Check SwiftTerm API for link detection
// terminal.linkRecognizer = .autodetect
// Configure font
updateFont(terminal, size: fontSize)
@ -87,8 +92,19 @@ struct TerminalHostingView: UIViewRepresentable {
func feedData(_ data: String) {
Task { @MainActor in
guard let terminal = terminal else { return }
// Store current scroll position before feeding data
let wasAtBottom = viewModel.isAutoScrollEnabled
// Feed the output to the terminal
terminal.feed(text: data)
// Auto-scroll to bottom if enabled
if wasAtBottom {
// SwiftTerm automatically scrolls when feeding data at bottom
// TODO: Check SwiftTerm API for explicit scrolling if needed
// terminal.scrollToBottom()
}
}
}
@ -105,7 +121,30 @@ struct TerminalHostingView: UIViewRepresentable {
}
func scrolled(source: SwiftTerm.TerminalView, position: Double) {
// Handle scroll if needed
// TODO: Implement scroll position tracking with SwiftTerm API
// The current implementation needs to be updated for the actual SwiftTerm API
/*
// Check if user manually scrolled away from bottom
if let terminal = terminal {
let buffer = terminal.buffer
let totalRows = buffer.lines.count
let viewportHeight = terminal.rows
let maxScroll = Double(max(0, totalRows - viewportHeight))
// If user scrolled away from bottom (with some tolerance)
let isAtBottom = position >= maxScroll - 5
Task { @MainActor in
if !isAtBottom && viewModel.isAutoScrollEnabled {
// User manually scrolled up - disable auto-scroll
viewModel.isAutoScrollEnabled = false
} else if isAtBottom && !viewModel.isAutoScrollEnabled {
// User scrolled back to bottom - re-enable auto-scroll
viewModel.isAutoScrollEnabled = true
}
}
}
*/
}
func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) {

View file

@ -3,8 +3,17 @@ import SwiftUI
struct TerminalToolbar: View {
let onSpecialKey: (TerminalInput.SpecialKey) -> Void
let onDismissKeyboard: () -> Void
let onRawInput: ((String) -> Void)?
@State private var showMoreKeys = false
init(onSpecialKey: @escaping (TerminalInput.SpecialKey) -> Void,
onDismissKeyboard: @escaping () -> Void,
onRawInput: ((String) -> Void)? = nil) {
self.onSpecialKey = onSpecialKey
self.onDismissKeyboard = onDismissKeyboard
self.onRawInput = onRawInput
}
var body: some View {
VStack(spacing: 0) {
Divider()
@ -75,29 +84,81 @@ struct TerminalToolbar: View {
Divider()
.background(Theme.Colors.cardBorder)
HStack(spacing: Theme.Spacing.xs) {
// Control keys
ToolbarButton(label: "CTRL+C") {
HapticFeedback.impact(.medium)
onSpecialKey(.ctrlC)
VStack(spacing: Theme.Spacing.xs) {
// First row of control keys
HStack(spacing: Theme.Spacing.xs) {
ToolbarButton(label: "CTRL+A") {
HapticFeedback.impact(.medium)
onSpecialKey(.ctrlA)
}
ToolbarButton(label: "CTRL+C") {
HapticFeedback.impact(.medium)
onSpecialKey(.ctrlC)
}
ToolbarButton(label: "CTRL+D") {
HapticFeedback.impact(.medium)
onSpecialKey(.ctrlD)
}
ToolbarButton(label: "CTRL+E") {
HapticFeedback.impact(.medium)
onSpecialKey(.ctrlE)
}
}
ToolbarButton(label: "CTRL+D") {
HapticFeedback.impact(.medium)
onSpecialKey(.ctrlD)
// Second row of control keys
HStack(spacing: Theme.Spacing.xs) {
ToolbarButton(label: "CTRL+L") {
HapticFeedback.impact(.medium)
onSpecialKey(.ctrlL)
}
ToolbarButton(label: "CTRL+Z") {
HapticFeedback.impact(.medium)
onSpecialKey(.ctrlZ)
}
ToolbarButton(label: "ENTER") {
HapticFeedback.impact(.light)
onSpecialKey(.enter)
}
ToolbarButton(label: "HOME") {
HapticFeedback.impact(.light)
// Send Ctrl+A for home
onSpecialKey(.ctrlA)
}
}
ToolbarButton(label: "CTRL+Z") {
HapticFeedback.impact(.medium)
onSpecialKey(.ctrlZ)
// Third row - custom Ctrl key input
HStack(spacing: Theme.Spacing.xs) {
Text("CTRL +")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
.padding(.leading, Theme.Spacing.sm)
ForEach(["K", "U", "W", "R", "T"], id: \.self) { letter in
ToolbarButton(label: letter, width: 44) {
HapticFeedback.impact(.medium)
// Send the control character for the letter
if let charCode = letter.first?.asciiValue {
let controlCharCode = Int(charCode - 64) // A=1, B=2, etc.
let controlChar = String(UnicodeScalar(controlCharCode)!)
// Use raw input if available, otherwise fall back to sending as text
if let onRawInput = onRawInput {
onRawInput(controlChar)
} else {
// Fallback - just send Ctrl+C
onSpecialKey(.ctrlC)
}
}
}
}
Spacer()
}
ToolbarButton(label: "ENTER") {
HapticFeedback.impact(.light)
onSpecialKey(.enter)
}
Spacer()
}
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)

View file

@ -8,6 +8,7 @@ struct TerminalView: View {
@StateObject private var viewModel: TerminalViewModel
@State private var fontSize: CGFloat = 14
@State private var showingFontSizeSheet = false
@State private var showingRecordingSheet = false
@State private var keyboardHeight: CGFloat = 0
@FocusState private var isInputFocused: Bool
@ -36,6 +37,9 @@ struct TerminalView: View {
}
.navigationTitle(session.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .bottomBar)
.toolbarBackground(.visible, for: .bottomBar)
.toolbarBackground(Theme.Colors.cardBackground, for: .bottomBar)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
@ -57,6 +61,27 @@ struct TerminalView: View {
Button(action: { viewModel.copyBuffer() }) {
Label("Copy All", systemImage: "doc.on.doc")
}
Divider()
if viewModel.castRecorder.isRecording {
Button(action: {
viewModel.stopRecording()
showingRecordingSheet = true
}) {
Label("Stop Recording", systemImage: "stop.circle.fill")
.foregroundColor(.red)
}
} else {
Button(action: { viewModel.startRecording() }) {
Label("Start Recording", systemImage: "record.circle")
}
}
Button(action: { showingRecordingSheet = true }) {
Label("Export Recording", systemImage: "square.and.arrow.up")
}
.disabled(viewModel.castRecorder.events.isEmpty)
} label: {
Image(systemName: "ellipsis.circle")
.foregroundColor(Theme.Colors.primaryAccent)
@ -66,6 +91,71 @@ struct TerminalView: View {
.sheet(isPresented: $showingFontSizeSheet) {
FontSizeSheet(fontSize: $fontSize)
}
.sheet(isPresented: $showingRecordingSheet) {
RecordingExportSheet(recorder: viewModel.castRecorder, sessionName: session.displayName)
}
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: "rectangle.split.3x1")
.font(.caption)
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
Text("\(viewModel.terminalCols) × \(viewModel.terminalRows)")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
}
}
Spacer()
// Session status
HStack(spacing: 4) {
Circle()
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.3))
.frame(width: 6, height: 6)
Text(session.isRunning ? "Running" : "Exited")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.5))
}
if let pid = session.pid {
Spacer()
Text("PID: \(pid)")
.font(Theme.Typography.terminalSystem(size: 12))
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
.onTapGesture {
UIPasteboard.general.string = String(pid)
HapticFeedback.notification(.success)
}
}
}
// Recording indicator
ToolbarItem(placement: .navigationBarTrailing) {
if viewModel.castRecorder.isRecording {
HStack(spacing: 4) {
Circle()
.fill(Color.red)
.frame(width: 8, height: 8)
.overlay(
Circle()
.fill(Color.red.opacity(0.3))
.frame(width: 16, height: 16)
.scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0)
.animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: viewModel.recordingPulse)
)
Text("REC")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.red)
}
.onAppear {
viewModel.recordingPulse = true
}
}
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.preferredColorScheme(.dark)
@ -137,6 +227,8 @@ struct TerminalView: View {
viewModel.sendInput(text)
},
onResize: { cols, rows in
viewModel.terminalCols = cols
viewModel.terminalRows = rows
viewModel.resize(cols: cols, rows: rows)
},
viewModel: viewModel
@ -153,6 +245,9 @@ struct TerminalView: View {
},
onDismissKeyboard: {
isInputFocused = false
},
onRawInput: { input in
viewModel.sendInput(input)
}
)
.transition(.move(edge: .bottom).combined(with: .opacity))
@ -167,14 +262,20 @@ class TerminalViewModel: ObservableObject {
@Published var isConnected = false
@Published var errorMessage: String?
@Published var terminalViewId = UUID()
@Published var terminalCols: Int = 0
@Published var terminalRows: Int = 0
@Published var isAutoScrollEnabled = true
@Published var recordingPulse = false
let session: Session
private var sseClient: SSEClient?
let castRecorder: CastRecorder
private var bufferWebSocketClient: BufferWebSocketClient?
var cancellables = Set<AnyCancellable>()
weak var terminalCoordinator: TerminalHostingView.Coordinator?
init(session: Session) {
self.session = session
self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24)
setupTerminal()
}
@ -182,33 +283,58 @@ class TerminalViewModel: ObservableObject {
// Terminal setup now handled by SimpleTerminalView
}
func startRecording() {
castRecorder.startRecording()
}
func stopRecording() {
castRecorder.stopRecording()
}
func connect() {
isConnecting = true
errorMessage = nil
guard let streamURL = APIClient.shared.streamURL(for: session.id) else {
errorMessage = "Failed to create stream URL"
isConnecting = false
return
// Create WebSocket client if needed
if bufferWebSocketClient == nil {
bufferWebSocketClient = BufferWebSocketClient()
}
// Load existing terminal snapshot first if session is already running
if session.isRunning {
Task {
await loadSnapshot()
// Connect to WebSocket
bufferWebSocketClient?.connect()
// Subscribe to terminal events
bufferWebSocketClient?.subscribe(to: session.id) { [weak self] event in
Task { @MainActor in
self?.handleWebSocketEvent(event)
}
}
sseClient = SSEClient()
Task {
for await event in sseClient!.connect(to: streamURL) {
handleTerminalEvent(event)
// Monitor connection status
bufferWebSocketClient?.$isConnected
.sink { [weak self] connected in
Task { @MainActor in
self?.isConnecting = false
self?.isConnected = connected
if !connected {
self?.errorMessage = "WebSocket disconnected"
} else {
self?.errorMessage = nil
}
}
}
}
.store(in: &cancellables)
isConnecting = false
isConnected = true
// Monitor connection errors
bufferWebSocketClient?.$connectionError
.compactMap { $0 }
.sink { [weak self] error in
Task { @MainActor in
self?.errorMessage = error.localizedDescription
self?.isConnecting = false
}
}
.store(in: &cancellables)
}
@MainActor
@ -227,39 +353,52 @@ class TerminalViewModel: ObservableObject {
}
func disconnect() {
sseClient?.disconnect()
sseClient = nil
bufferWebSocketClient?.unsubscribe(from: session.id)
bufferWebSocketClient?.disconnect()
bufferWebSocketClient = nil
isConnected = false
}
@MainActor
private func handleTerminalEvent(_ event: TerminalEvent) {
private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) {
switch event {
case .header(let header):
case .header(let width, let height):
// Initial terminal setup
print("Terminal initialized: \(header.width)x\(header.height)")
print("Terminal initialized: \(width)x\(height)")
terminalCols = width
terminalRows = height
// The terminal will be resized when created
case .output(_, let data):
case .output(let timestamp, let data):
// Feed output data directly to the terminal
terminalCoordinator?.feedData(data)
// Record output if recording
castRecorder.recordOutput(data)
case .resize(_, let dimensions):
case .resize(let timestamp, let dimensions):
// Parse dimensions like "120x30"
let parts = dimensions.split(separator: "x")
if parts.count == 2,
let cols = Int(parts[0]),
let rows = Int(parts[1]) {
// Handle resize if needed
// Update terminal dimensions
terminalCols = cols
terminalRows = rows
print("Terminal resize: \(cols)x\(rows)")
// Record resize event
castRecorder.recordResize(cols: cols, rows: rows)
}
case .exit(let code, _):
case .exit(let code):
// Session has exited
isConnected = false
if code != 0 {
errorMessage = "Session exited with code \(code)"
}
// Stop recording if active
if castRecorder.isRecording {
stopRecording()
}
}
}