From d7dd436b2e1b6f05e01a2be2a84666f421e43fb9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 20 Jun 2025 07:01:01 +0200 Subject: [PATCH] bring iOS to feture parity with frontend --- ios/VibeTunnel.xcodeproj/project.pbxproj | 534 ++++++++++++------ ios/VibeTunnel/App/ContentView.swift | 16 + ios/VibeTunnel/App/VibeTunnelApp.swift | 31 + ios/VibeTunnel/Models/CastFile.swift | 190 +++++++ ios/VibeTunnel/Models/ServerConfig.swift | 13 + ios/VibeTunnel/Models/TerminalData.swift | 2 + ios/VibeTunnel/Models/TerminalSnapshot.swift | 54 ++ ios/VibeTunnel/Resources/Info.plist | 185 +++--- ios/VibeTunnel/Services/APIClient.swift | 24 + .../Services/BufferWebSocketClient.swift | 340 +++++++++++ ios/VibeTunnel/Services/SSEClient.swift | 117 ---- .../Views/Connection/ConnectionView.swift | 12 +- .../Views/Connection/ServerConfigForm.swift | 19 +- .../Views/Sessions/SessionCardView.swift | 164 +++++- .../Views/Sessions/SessionListView.swift | 73 ++- .../Views/Terminal/CastPlayerView.swift | 374 ++++++++++++ .../Views/Terminal/RecordingExportSheet.swift | 153 +++++ .../Views/Terminal/TerminalHostingView.swift | 41 +- .../Views/Terminal/TerminalToolbar.swift | 97 +++- .../Views/Terminal/TerminalView.swift | 191 ++++++- 20 files changed, 2187 insertions(+), 443 deletions(-) create mode 100644 ios/VibeTunnel/Models/CastFile.swift create mode 100644 ios/VibeTunnel/Models/TerminalSnapshot.swift create mode 100644 ios/VibeTunnel/Services/BufferWebSocketClient.swift delete mode 100644 ios/VibeTunnel/Services/SSEClient.swift create mode 100644 ios/VibeTunnel/Views/Terminal/CastPlayerView.swift create mode 100644 ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift diff --git a/ios/VibeTunnel.xcodeproj/project.pbxproj b/ios/VibeTunnel.xcodeproj/project.pbxproj index 1b4ab5fb..e0069ca5 100644 --- a/ios/VibeTunnel.xcodeproj/project.pbxproj +++ b/ios/VibeTunnel.xcodeproj/project.pbxproj @@ -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 = ""; }; + 0C9F52961C0CE3C11D19B4AA /* SessionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = ""; }; + 1A35878ADF0225DB9359CB3B /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; + 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 = ""; }; + 2A8B3691F13D288B60A420A0 /* FileBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileBrowserView.swift; sourceTree = ""; }; + 2C03EA6806BCDCDAB311FFD3 /* BufferWebSocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BufferWebSocketClient.swift; sourceTree = ""; }; + 39C1575896A90B1A29B06863 /* SessionCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCreateView.swift; sourceTree = ""; }; + 3A3CFA0FABA146069CF88DF9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 490C9252E0C8919CEA6975E1 /* TerminalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalData.swift; sourceTree = ""; }; + 55C288DF07D7B08AE2F9D629 /* RecordingExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingExportSheet.swift; sourceTree = ""; }; + 64D5E795FF5B9B26E1632820 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; + 6C3F9DE217D8030C0E4BAA4F /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = ""; }; + 6CDE7FCB586F0B97ABA6643B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 7195183E692F0E3F7FB6BD46 /* VibeTunnelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibeTunnelApp.swift; sourceTree = ""; }; + 71EAA6D6962071517130743C /* FontSizeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSizeSheet.swift; sourceTree = ""; }; + 8BBBE21208D5FB72E5E43BC8 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = ""; }; + A34681CD05E181BE71AC6A00 /* SessionCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCardView.swift; sourceTree = ""; }; + A74BA890E4D1DF214A766499 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A8250BC491000BA2517528BC /* ServerConfigForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigForm.swift; sourceTree = ""; }; + ABD2A3A081E2D10E2EEB45CA /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + AE2241BAA58600E11E79753A /* ConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionView.swift; sourceTree = ""; }; + B1BD0BD408900CC6C0B1F89C /* TerminalHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalHostingView.swift; sourceTree = ""; }; + BC6F30F9F8BF1042B1062802 /* CastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastPlayerView.swift; sourceTree = ""; }; + DB644C5EB44BF1B46E31349C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + E0223E64AC1DC79EF399917A /* TerminalSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSnapshot.swift; sourceTree = ""; }; + E6CC60E9A331AF6B07BAE2B3 /* CastFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastFile.swift; sourceTree = ""; }; + F05B14A3AD2EAB350D390626 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = ""; }; /* 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 = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - /* Begin PBXFrameworksBuildPhase section */ 2FD464BF4BA1F22E2EBF0847 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -55,6 +87,27 @@ ); sourceTree = ""; }; + 1F5FB514206922162C034878 /* Utils */ = { + isa = PBXGroup; + children = ( + 6CDE7FCB586F0B97ABA6643B /* Theme.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 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 = ""; + }; 7866022B46DD9C411CAEDA26 /* Products */ = { isa = PBXGroup; children = ( @@ -63,24 +116,114 @@ name = Products; sourceTree = ""; }; + 86B28FA8FD27668A273B1C3E /* Services */ = { + isa = PBXGroup; + children = ( + 22B067C79374E10C55450BE1 /* APIClient.swift */, + 2C03EA6806BCDCDAB311FFD3 /* BufferWebSocketClient.swift */, + 0BADCA88F0695AF46222B3AB /* SessionService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 9E3F35439BD7C1A5A0F15879 /* App */ = { + isa = PBXGroup; + children = ( + 3A3CFA0FABA146069CF88DF9 /* ContentView.swift */, + 7195183E692F0E3F7FB6BD46 /* VibeTunnelApp.swift */, + ); + path = App; + sourceTree = ""; + }; + A4640175A87A16583F0A76A8 /* VibeTunnel */ = { + isa = PBXGroup; + children = ( + 9E3F35439BD7C1A5A0F15879 /* App */, + FCC1EFC1E57CFF81577BF71B /* Models */, + AC551FF9D498BF578D8F8A28 /* Resources */, + 86B28FA8FD27668A273B1C3E /* Services */, + 1F5FB514206922162C034878 /* Utils */, + C8CD45CB08E314A8A25890CA /* Views */, + ); + path = VibeTunnel; + sourceTree = ""; + }; + AC551FF9D498BF578D8F8A28 /* Resources */ = { + isa = PBXGroup; + children = ( + A74BA890E4D1DF214A766499 /* Assets.xcassets */, + DB644C5EB44BF1B46E31349C /* Info.plist */, + ); + path = Resources; + sourceTree = ""; + }; + C8CD45CB08E314A8A25890CA /* Views */ = { + isa = PBXGroup; + children = ( + D36708B0030B5480E17544B5 /* Common */, + EA3CCEFC5FA2D00712C2F0B4 /* Connection */, + F6E767797FA3FEE7AEA9782C /* Sessions */, + 65A45DAAB11ECFC66DA7A032 /* Terminal */, + 2A8B3691F13D288B60A420A0 /* FileBrowserView.swift */, + ); + path = Views; + sourceTree = ""; + }; + D36708B0030B5480E17544B5 /* Common */ = { + isa = PBXGroup; + children = ( + ABD2A3A081E2D10E2EEB45CA /* LoadingView.swift */, + ); + path = Common; + sourceTree = ""; + }; + EA3CCEFC5FA2D00712C2F0B4 /* Connection */ = { + isa = PBXGroup; + children = ( + AE2241BAA58600E11E79753A /* ConnectionView.swift */, + A8250BC491000BA2517528BC /* ServerConfigForm.swift */, + ); + path = Connection; + sourceTree = ""; + }; + F6E767797FA3FEE7AEA9782C /* Sessions */ = { + isa = PBXGroup; + children = ( + A34681CD05E181BE71AC6A00 /* SessionCardView.swift */, + 39C1575896A90B1A29B06863 /* SessionCreateView.swift */, + 0C9F52961C0CE3C11D19B4AA /* SessionListView.swift */, + ); + path = Sessions; + sourceTree = ""; + }; + 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 = ""; + }; /* 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 */; } diff --git a/ios/VibeTunnel/App/ContentView.swift b/ios/VibeTunnel/App/ContentView.swift index b1030633..bb61563f 100644 --- a/ios/VibeTunnel/App/ContentView.swift +++ b/ios/VibeTunnel/App/ContentView.swift @@ -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) + } + } } } \ No newline at end of file diff --git a/ios/VibeTunnel/App/VibeTunnelApp.swift b/ios/VibeTunnel/App/VibeTunnelApp.swift index 9b94e617..1057a159 100644 --- a/ios/VibeTunnel/App/VibeTunnelApp.swift +++ b/ios/VibeTunnel/App/VibeTunnelApp.swift @@ -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 + } } \ No newline at end of file diff --git a/ios/VibeTunnel/Models/CastFile.swift b/ios/VibeTunnel/Models/CastFile.swift new file mode 100644 index 00000000..098a1c39 --- /dev/null +++ b/ios/VibeTunnel/Models/CastFile.swift @@ -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..= 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() + } + } + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Models/ServerConfig.swift b/ios/VibeTunnel/Models/ServerConfig.swift index c0f0861a..54622448 100644 --- a/ios/VibeTunnel/Models/ServerConfig.swift +++ b/ios/VibeTunnel/Models/ServerConfig.swift @@ -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)" + } } \ No newline at end of file diff --git a/ios/VibeTunnel/Models/TerminalData.swift b/ios/VibeTunnel/Models/TerminalData.swift index dfff2339..ad009a84 100644 --- a/ios/VibeTunnel/Models/TerminalData.swift +++ b/ios/VibeTunnel/Models/TerminalData.swift @@ -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]? } diff --git a/ios/VibeTunnel/Models/TerminalSnapshot.swift b/ios/VibeTunnel/Models/TerminalSnapshot.swift new file mode 100644 index 00000000..93d4e0b1 --- /dev/null +++ b/ios/VibeTunnel/Models/TerminalSnapshot.swift @@ -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 + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Resources/Info.plist b/ios/VibeTunnel/Resources/Info.plist index f55ad994..6320828f 100644 --- a/ios/VibeTunnel/Resources/Info.plist +++ b/ios/VibeTunnel/Resources/Info.plist @@ -2,73 +2,120 @@ - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - VibeTunnel - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - - UIApplicationSupportsIndirectInputEvents - - UILaunchScreen - - UIColorName - TerminalBackground - UIImageName - LaunchIcon - - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - UIAppFonts - - FiraCode-Regular.ttf - FiraCode-Medium.ttf - FiraCode-Bold.ttf - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleLightContent - UIViewControllerBasedStatusBarAppearance - + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + CFBundleDisplayName + VibeTunnel + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIColorName + TerminalBackground + UIImageName + LaunchIcon + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIAppFonts + + FiraCode-Regular.ttf + FiraCode-Medium.ttf + FiraCode-Bold.ttf + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleLightContent + UIViewControllerBasedStatusBarAppearance + + CFBundleURLTypes + + + CFBundleURLSchemes + + vibetunnel + + CFBundleURLName + com.vibetunnel.app + + + CFBundleDocumentTypes + + + CFBundleTypeName + Asciinema Recording + CFBundleTypeRole + Viewer + LSHandlerRank + Default + LSItemContentTypes + + public.cast + public.plain-text + + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + public.cast + UTTypeDescription + Asciinema Recording + UTTypeConformsTo + + public.plain-text + + UTTypeTagSpecification + + public.filename-extension + + cast + + + + - \ No newline at end of file + diff --git a/ios/VibeTunnel/Services/APIClient.swift b/ios/VibeTunnel/Services/APIClient.swift index c362d357..9aaa005b 100644 --- a/ios/VibeTunnel/Services/APIClient.swift +++ b/ios/VibeTunnel/Services/APIClient.swift @@ -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 diff --git a/ios/VibeTunnel/Services/BufferWebSocketClient.swift b/ios/VibeTunnel/Services/BufferWebSocketClient.swift new file mode 100644 index 00000000..0eb79929 --- /dev/null +++ b/ios/VibeTunnel/Services/BufferWebSocketClient.swift @@ -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.. 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() + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Services/SSEClient.swift b/ios/VibeTunnel/Services/SSEClient.swift deleted file mode 100644 index 96d63b7c..00000000 --- a/ios/VibeTunnel/Services/SSEClient.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation - -final class SSEClient: NSObject, @unchecked Sendable { - private var eventSource: URLSessionDataTask? - private var session: URLSession? - private var streamContinuation: AsyncStream.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 { - 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.. Void) { - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - completionHandler(.cancel) - return - } - completionHandler(.allow) - } -} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Connection/ConnectionView.swift b/ios/VibeTunnel/Views/Connection/ConnectionView.swift index 3b107970..3d74c979 100644 --- a/ios/VibeTunnel/Views/Connection/ConnectionView.swift +++ b/ios/VibeTunnel/Views/Connection/ConnectionView.swift @@ -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 { diff --git a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift index ddc50591..9dd5d190 100644 --- a/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift +++ b/ios/VibeTunnel/Views/Connection/ServerConfigForm.swift @@ -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) { diff --git a/ios/VibeTunnel/Views/Sessions/SessionCardView.swift b/ios/VibeTunnel/Views/Sessions/SessionCardView.swift index 3e2bd121..36eb8533 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionCardView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionCardView.swift @@ -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() + } } } \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Sessions/SessionListView.swift b/ios/VibeTunnel/Views/Sessions/SessionListView.swift index eacfa636..a305b210 100644 --- a/ios/VibeTunnel/Views/Sessions/SessionListView.swift +++ b/ios/VibeTunnel/Views/Sessions/SessionListView.swift @@ -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 { diff --git a/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift b/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift new file mode 100644 index 00000000..b2fddaff --- /dev/null +++ b/ios/VibeTunnel/Views/Terminal/CastPlayerView.swift @@ -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? + + 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() + } + } + } +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift b/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift new file mode 100644 index 00000000..313f8507 --- /dev/null +++ b/ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift @@ -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) {} +} \ No newline at end of file diff --git a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift index a02a3bd1..843a3c96 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalHostingView.swift @@ -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) { diff --git a/ios/VibeTunnel/Views/Terminal/TerminalToolbar.swift b/ios/VibeTunnel/Views/Terminal/TerminalToolbar.swift index 343f3bd9..b711950b 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalToolbar.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalToolbar.swift @@ -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) diff --git a/ios/VibeTunnel/Views/Terminal/TerminalView.swift b/ios/VibeTunnel/Views/Terminal/TerminalView.swift index 10630009..01cf8c2c 100644 --- a/ios/VibeTunnel/Views/Terminal/TerminalView.swift +++ b/ios/VibeTunnel/Views/Terminal/TerminalView.swift @@ -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() 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() + } } }