mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-10 12:05:53 +00:00
bring iOS to feture parity with frontend
This commit is contained in:
parent
fdafb0b522
commit
d7dd436b2e
20 changed files with 2187 additions and 443 deletions
|
|
@ -3,38 +3,70 @@
|
|||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
10655A6075D28A2A6565A0A3 /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9F52961C0CE3C11D19B4AA /* SessionListView.swift */; };
|
||||
10A85D034A743E712DC7EC9C /* SessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BADCA88F0695AF46222B3AB /* SessionService.swift */; };
|
||||
252F7C50197317C1DD320C5D /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD2A3A081E2D10E2EEB45CA /* LoadingView.swift */; };
|
||||
2F16BC1DC3080F71DA9CBAFF /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05B14A3AD2EAB350D390626 /* FileEntry.swift */; };
|
||||
3957A92AFDDD4125A06826C5 /* CastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC6F30F9F8BF1042B1062802 /* CastPlayerView.swift */; };
|
||||
3B6417754B3785129AEAABD9 /* TerminalSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0223E64AC1DC79EF399917A /* TerminalSnapshot.swift */; };
|
||||
45EBF8786E05DF1026766DB2 /* FontSizeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71EAA6D6962071517130743C /* FontSizeSheet.swift */; };
|
||||
538B160DCA0BC2DF4752A363 /* SessionCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34681CD05E181BE71AC6A00 /* SessionCardView.swift */; };
|
||||
562BA6B4D1BB3797259A29E8 /* FileBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B3691F13D288B60A420A0 /* FileBrowserView.swift */; };
|
||||
671399118731954CBF63B4C0 /* ServerConfigForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8250BC491000BA2517528BC /* ServerConfigForm.swift */; };
|
||||
7125A00FFE081D6A721ABF63 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A74BA890E4D1DF214A766499 /* Assets.xcassets */; };
|
||||
7272E2FE084BB86440A69701 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 51D59A51D50DE7492ECEAA6F /* SwiftTerm */; };
|
||||
736787173E49F49A351AA43C /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBBE21208D5FB72E5E43BC8 /* ServerConfig.swift */; };
|
||||
7B665F33A07E379DA41E9291 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3F9DE217D8030C0E4BAA4F /* TerminalToolbar.swift */; };
|
||||
7D5D17EE18D016FB9633A634 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D5E795FF5B9B26E1632820 /* Session.swift */; };
|
||||
901CD6449111764E95CA6625 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3CFA0FABA146069CF88DF9 /* ContentView.swift */; };
|
||||
A7BC95D91D6DD1A10C3E6DCC /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A35878ADF0225DB9359CB3B /* TerminalView.swift */; };
|
||||
A882FB20F660EAB0650A11BD /* VibeTunnelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7195183E692F0E3F7FB6BD46 /* VibeTunnelApp.swift */; };
|
||||
A99CE758ED28F123B74D6DDD /* BufferWebSocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C03EA6806BCDCDAB311FFD3 /* BufferWebSocketClient.swift */; };
|
||||
B1F788D114A1145E96939A07 /* SessionCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C1575896A90B1A29B06863 /* SessionCreateView.swift */; };
|
||||
B41BEF522D66FA483804F671 /* TerminalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490C9252E0C8919CEA6975E1 /* TerminalData.swift */; };
|
||||
B611B173C0E3141B0A651056 /* TerminalHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1BD0BD408900CC6C0B1F89C /* TerminalHostingView.swift */; };
|
||||
C6835CF39EAEEAA03DDBDB44 /* RecordingExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C288DF07D7B08AE2F9D629 /* RecordingExportSheet.swift */; };
|
||||
E756EC021E402CB2F40ED3E5 /* CastFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6CC60E9A331AF6B07BAE2B3 /* CastFile.swift */; };
|
||||
E872C6E384B4E05935EFD17F /* ConnectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2241BAA58600E11E79753A /* ConnectionView.swift */; };
|
||||
F36C0895F603390F3CAD3DCD /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22B067C79374E10C55450BE1 /* APIClient.swift */; };
|
||||
F828114315E4A151327A556B /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDE7FCB586F0B97ABA6643B /* Theme.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
216D52E392D8F0CDC6358BB8 /* VibeTunnel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VibeTunnel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0BADCA88F0695AF46222B3AB /* SessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionService.swift; sourceTree = "<group>"; };
|
||||
0C9F52961C0CE3C11D19B4AA /* SessionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = "<group>"; };
|
||||
1A35878ADF0225DB9359CB3B /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
|
||||
216D52E392D8F0CDC6358BB8 /* VibeTunnel.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = VibeTunnel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
22B067C79374E10C55450BE1 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||
2A8B3691F13D288B60A420A0 /* FileBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileBrowserView.swift; sourceTree = "<group>"; };
|
||||
2C03EA6806BCDCDAB311FFD3 /* BufferWebSocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BufferWebSocketClient.swift; sourceTree = "<group>"; };
|
||||
39C1575896A90B1A29B06863 /* SessionCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCreateView.swift; sourceTree = "<group>"; };
|
||||
3A3CFA0FABA146069CF88DF9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
490C9252E0C8919CEA6975E1 /* TerminalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalData.swift; sourceTree = "<group>"; };
|
||||
55C288DF07D7B08AE2F9D629 /* RecordingExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingExportSheet.swift; sourceTree = "<group>"; };
|
||||
64D5E795FF5B9B26E1632820 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
|
||||
6C3F9DE217D8030C0E4BAA4F /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = "<group>"; };
|
||||
6CDE7FCB586F0B97ABA6643B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
7195183E692F0E3F7FB6BD46 /* VibeTunnelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibeTunnelApp.swift; sourceTree = "<group>"; };
|
||||
71EAA6D6962071517130743C /* FontSizeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontSizeSheet.swift; sourceTree = "<group>"; };
|
||||
8BBBE21208D5FB72E5E43BC8 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
|
||||
A34681CD05E181BE71AC6A00 /* SessionCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCardView.swift; sourceTree = "<group>"; };
|
||||
A74BA890E4D1DF214A766499 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A8250BC491000BA2517528BC /* ServerConfigForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigForm.swift; sourceTree = "<group>"; };
|
||||
ABD2A3A081E2D10E2EEB45CA /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
|
||||
AE2241BAA58600E11E79753A /* ConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionView.swift; sourceTree = "<group>"; };
|
||||
B1BD0BD408900CC6C0B1F89C /* TerminalHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalHostingView.swift; sourceTree = "<group>"; };
|
||||
BC6F30F9F8BF1042B1062802 /* CastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastPlayerView.swift; sourceTree = "<group>"; };
|
||||
DB644C5EB44BF1B46E31349C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
E0223E64AC1DC79EF399917A /* TerminalSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSnapshot.swift; sourceTree = "<group>"; };
|
||||
E6CC60E9A331AF6B07BAE2B3 /* CastFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CastFile.swift; sourceTree = "<group>"; };
|
||||
F05B14A3AD2EAB350D390626 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Resources/Info.plist,
|
||||
);
|
||||
target = 788687F02DFF4FCB00B22C15 /* VibeTunnel */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A4640175A87A16583F0A76A8 /* VibeTunnel */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
78868B612DFF808300B22C15 /* Exceptions for "VibeTunnel" folder in "VibeTunnel" target */,
|
||||
);
|
||||
path = VibeTunnel;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
2FD464BF4BA1F22E2EBF0847 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
|
|
@ -55,6 +87,27 @@
|
|||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1F5FB514206922162C034878 /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6CDE7FCB586F0B97ABA6643B /* Theme.swift */,
|
||||
);
|
||||
path = Utils;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
65A45DAAB11ECFC66DA7A032 /* Terminal */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BC6F30F9F8BF1042B1062802 /* CastPlayerView.swift */,
|
||||
71EAA6D6962071517130743C /* FontSizeSheet.swift */,
|
||||
55C288DF07D7B08AE2F9D629 /* RecordingExportSheet.swift */,
|
||||
B1BD0BD408900CC6C0B1F89C /* TerminalHostingView.swift */,
|
||||
6C3F9DE217D8030C0E4BAA4F /* TerminalToolbar.swift */,
|
||||
1A35878ADF0225DB9359CB3B /* TerminalView.swift */,
|
||||
);
|
||||
path = Terminal;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7866022B46DD9C411CAEDA26 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -63,24 +116,114 @@
|
|||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
86B28FA8FD27668A273B1C3E /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
22B067C79374E10C55450BE1 /* APIClient.swift */,
|
||||
2C03EA6806BCDCDAB311FFD3 /* BufferWebSocketClient.swift */,
|
||||
0BADCA88F0695AF46222B3AB /* SessionService.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9E3F35439BD7C1A5A0F15879 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3A3CFA0FABA146069CF88DF9 /* ContentView.swift */,
|
||||
7195183E692F0E3F7FB6BD46 /* VibeTunnelApp.swift */,
|
||||
);
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A4640175A87A16583F0A76A8 /* VibeTunnel */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9E3F35439BD7C1A5A0F15879 /* App */,
|
||||
FCC1EFC1E57CFF81577BF71B /* Models */,
|
||||
AC551FF9D498BF578D8F8A28 /* Resources */,
|
||||
86B28FA8FD27668A273B1C3E /* Services */,
|
||||
1F5FB514206922162C034878 /* Utils */,
|
||||
C8CD45CB08E314A8A25890CA /* Views */,
|
||||
);
|
||||
path = VibeTunnel;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AC551FF9D498BF578D8F8A28 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A74BA890E4D1DF214A766499 /* Assets.xcassets */,
|
||||
DB644C5EB44BF1B46E31349C /* Info.plist */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C8CD45CB08E314A8A25890CA /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D36708B0030B5480E17544B5 /* Common */,
|
||||
EA3CCEFC5FA2D00712C2F0B4 /* Connection */,
|
||||
F6E767797FA3FEE7AEA9782C /* Sessions */,
|
||||
65A45DAAB11ECFC66DA7A032 /* Terminal */,
|
||||
2A8B3691F13D288B60A420A0 /* FileBrowserView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D36708B0030B5480E17544B5 /* Common */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ABD2A3A081E2D10E2EEB45CA /* LoadingView.swift */,
|
||||
);
|
||||
path = Common;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EA3CCEFC5FA2D00712C2F0B4 /* Connection */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AE2241BAA58600E11E79753A /* ConnectionView.swift */,
|
||||
A8250BC491000BA2517528BC /* ServerConfigForm.swift */,
|
||||
);
|
||||
path = Connection;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F6E767797FA3FEE7AEA9782C /* Sessions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A34681CD05E181BE71AC6A00 /* SessionCardView.swift */,
|
||||
39C1575896A90B1A29B06863 /* SessionCreateView.swift */,
|
||||
0C9F52961C0CE3C11D19B4AA /* SessionListView.swift */,
|
||||
);
|
||||
path = Sessions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FCC1EFC1E57CFF81577BF71B /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E6CC60E9A331AF6B07BAE2B3 /* CastFile.swift */,
|
||||
F05B14A3AD2EAB350D390626 /* FileEntry.swift */,
|
||||
8BBBE21208D5FB72E5E43BC8 /* ServerConfig.swift */,
|
||||
64D5E795FF5B9B26E1632820 /* Session.swift */,
|
||||
490C9252E0C8919CEA6975E1 /* TerminalData.swift */,
|
||||
E0223E64AC1DC79EF399917A /* TerminalSnapshot.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
788687F02DFF4FCB00B22C15 /* VibeTunnel */ = {
|
||||
9F82885C40F6FE67B5339EB3 /* VibeTunnel */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 5D02E41CC1F088CA3F2F4A4C /* Build configuration list for PBXNativeTarget "VibeTunnel" */;
|
||||
buildConfigurationList = 8D99F6108356CC0D04FBF9FD /* Build configuration list for PBXNativeTarget "VibeTunnel" */;
|
||||
buildPhases = (
|
||||
E978695B99EFF28BD859BBBB /* Sources */,
|
||||
B4E21600DA0180F73AA29DAB /* Sources */,
|
||||
1C0E69C8ECC6DD66167C8DED /* Resources */,
|
||||
2FD464BF4BA1F22E2EBF0847 /* Frameworks */,
|
||||
04DC09B2F6FC36DD72CA87FE /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A4640175A87A16583F0A76A8 /* VibeTunnel */,
|
||||
);
|
||||
name = VibeTunnel;
|
||||
packageProductDependencies = (
|
||||
51D59A51D50DE7492ECEAA6F /* SwiftTerm */,
|
||||
|
|
@ -92,201 +235,153 @@
|
|||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
0DD83B4196D66608380E46BA /* Project object */ = {
|
||||
4C82CEFDA8E4EFE0B37538D7 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastUpgradeCheck = 2600;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
788687F02DFF4FCB00B22C15 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
9F82885C40F6FE67B5339EB3 = {
|
||||
DevelopmentTeam = "";
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = F88CDE43C19B6F98C24FAE6B /* Build configuration list for PBXProject "VibeTunnel" */;
|
||||
buildConfigurationList = 6D69C4974433AED85B9B8A62 /* Build configuration list for PBXProject "VibeTunnel" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
en,
|
||||
);
|
||||
mainGroup = 0DD83B4196D66608380E46BB;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
D39DDAD08E6E965F2F02ED4B /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||
99D7F1C619326805A282313E /* XCRemoteSwiftPackageReference "SwiftTerm" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 7866022B46DD9C411CAEDA26 /* Products */;
|
||||
preferredProjectObjectVersion = 54;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
788687F02DFF4FCB00B22C15 /* VibeTunnel */,
|
||||
9F82885C40F6FE67B5339EB3 /* VibeTunnel */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
04DC09B2F6FC36DD72CA87FE /* Resources */ = {
|
||||
1C0E69C8ECC6DD66167C8DED /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7125A00FFE081D6A721ABF63 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
E978695B99EFF28BD859BBBB /* Sources */ = {
|
||||
B4E21600DA0180F73AA29DAB /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F36C0895F603390F3CAD3DCD /* APIClient.swift in Sources */,
|
||||
A99CE758ED28F123B74D6DDD /* BufferWebSocketClient.swift in Sources */,
|
||||
E756EC021E402CB2F40ED3E5 /* CastFile.swift in Sources */,
|
||||
3957A92AFDDD4125A06826C5 /* CastPlayerView.swift in Sources */,
|
||||
E872C6E384B4E05935EFD17F /* ConnectionView.swift in Sources */,
|
||||
901CD6449111764E95CA6625 /* ContentView.swift in Sources */,
|
||||
562BA6B4D1BB3797259A29E8 /* FileBrowserView.swift in Sources */,
|
||||
2F16BC1DC3080F71DA9CBAFF /* FileEntry.swift in Sources */,
|
||||
45EBF8786E05DF1026766DB2 /* FontSizeSheet.swift in Sources */,
|
||||
252F7C50197317C1DD320C5D /* LoadingView.swift in Sources */,
|
||||
C6835CF39EAEEAA03DDBDB44 /* RecordingExportSheet.swift in Sources */,
|
||||
736787173E49F49A351AA43C /* ServerConfig.swift in Sources */,
|
||||
671399118731954CBF63B4C0 /* ServerConfigForm.swift in Sources */,
|
||||
7D5D17EE18D016FB9633A634 /* Session.swift in Sources */,
|
||||
538B160DCA0BC2DF4752A363 /* SessionCardView.swift in Sources */,
|
||||
B1F788D114A1145E96939A07 /* SessionCreateView.swift in Sources */,
|
||||
10655A6075D28A2A6565A0A3 /* SessionListView.swift in Sources */,
|
||||
10A85D034A743E712DC7EC9C /* SessionService.swift in Sources */,
|
||||
B41BEF522D66FA483804F671 /* TerminalData.swift in Sources */,
|
||||
B611B173C0E3141B0A651056 /* TerminalHostingView.swift in Sources */,
|
||||
3B6417754B3785129AEAABD9 /* TerminalSnapshot.swift in Sources */,
|
||||
7B665F33A07E379DA41E9291 /* TerminalToolbar.swift in Sources */,
|
||||
A7BC95D91D6DD1A10C3E6DCC /* TerminalView.swift in Sources */,
|
||||
F828114315E4A151327A556B /* Theme.swift in Sources */,
|
||||
A882FB20F660EAB0650A11BD /* VibeTunnelApp.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
01B44B31B4E8DD38FB9D84EC /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
2EB7D99F6AAB1EF82CFA5C1A /* Release */ = {
|
||||
2287F1E073ABA2294F8D65E6 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = NO;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.ios;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 6.0;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
6BDD461861CF6891A0D3CAAA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = s;
|
||||
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.ios;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
7C3A8A0F577E7AD8E7C60A66 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = NO;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
DD6BC2F25AD96D9BD4FC49E0 /* Release */ = {
|
||||
E5ADA5251EC344824C852777 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
|
|
@ -312,12 +407,90 @@
|
|||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"DEBUG=1",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F5CF3F9AC6801B6BE276B81C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
|
|
@ -325,42 +498,49 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = VibeTunnel/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.vibetunnel.ios;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
5D02E41CC1F088CA3F2F4A4C /* Build configuration list for PBXNativeTarget "VibeTunnel" */ = {
|
||||
6D69C4974433AED85B9B8A62 /* Build configuration list for PBXProject "VibeTunnel" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
7C3A8A0F577E7AD8E7C60A66 /* Debug */,
|
||||
2EB7D99F6AAB1EF82CFA5C1A /* Release */,
|
||||
E5ADA5251EC344824C852777 /* Debug */,
|
||||
F5CF3F9AC6801B6BE276B81C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
F88CDE43C19B6F98C24FAE6B /* Build configuration list for PBXProject "VibeTunnel" */ = {
|
||||
8D99F6108356CC0D04FBF9FD /* Build configuration list for PBXNativeTarget "VibeTunnel" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
01B44B31B4E8DD38FB9D84EC /* Debug */,
|
||||
DD6BC2F25AD96D9BD4FC49E0 /* Release */,
|
||||
2287F1E073ABA2294F8D65E6 /* Debug */,
|
||||
6BDD461861CF6891A0D3CAAA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
D39DDAD08E6E965F2F02ED4B /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
||||
99D7F1C619326805A282313E /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm";
|
||||
requirement = {
|
||||
|
|
@ -373,10 +553,10 @@
|
|||
/* Begin XCSwiftPackageProductDependency section */
|
||||
51D59A51D50DE7492ECEAA6F /* SwiftTerm */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D39DDAD08E6E965F2F02ED4B /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||
package = 99D7F1C619326805A282313E /* XCRemoteSwiftPackageReference "SwiftTerm" */;
|
||||
productName = SwiftTerm;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 0DD83B4196D66608380E46BA /* Project object */;
|
||||
rootObject = 4C82CEFDA8E4EFE0B37538D7 /* Project object */;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
190
ios/VibeTunnel/Models/CastFile.swift
Normal file
190
ios/VibeTunnel/Models/CastFile.swift
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import Foundation
|
||||
|
||||
// Asciinema cast v2 format support
|
||||
struct CastFile: Codable {
|
||||
let version: Int
|
||||
let width: Int
|
||||
let height: Int
|
||||
let timestamp: TimeInterval?
|
||||
let title: String?
|
||||
let env: [String: String]?
|
||||
let theme: CastTheme?
|
||||
|
||||
struct CastTheme: Codable {
|
||||
let fg: String?
|
||||
let bg: String?
|
||||
let palette: String?
|
||||
}
|
||||
}
|
||||
|
||||
struct CastEvent: Codable {
|
||||
let time: TimeInterval
|
||||
let type: String
|
||||
let data: String
|
||||
}
|
||||
|
||||
// Cast file recorder for terminal sessions
|
||||
@MainActor
|
||||
class CastRecorder: ObservableObject {
|
||||
@Published var isRecording = false
|
||||
@Published var recordingStartTime: Date?
|
||||
@Published var events: [CastEvent] = []
|
||||
|
||||
private let sessionId: String
|
||||
private let width: Int
|
||||
private let height: Int
|
||||
private var startTime: TimeInterval = 0
|
||||
|
||||
init(sessionId: String, width: Int = 80, height: Int = 24) {
|
||||
self.sessionId = sessionId
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
guard !isRecording else { return }
|
||||
|
||||
isRecording = true
|
||||
recordingStartTime = Date()
|
||||
startTime = Date().timeIntervalSince1970
|
||||
events.removeAll()
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
guard isRecording else { return }
|
||||
|
||||
isRecording = false
|
||||
recordingStartTime = nil
|
||||
}
|
||||
|
||||
func recordOutput(_ data: String) {
|
||||
guard isRecording else { return }
|
||||
|
||||
let currentTime = Date().timeIntervalSince1970
|
||||
let relativeTime = currentTime - startTime
|
||||
|
||||
let event = CastEvent(
|
||||
time: relativeTime,
|
||||
type: "o", // output
|
||||
data: data
|
||||
)
|
||||
|
||||
events.append(event)
|
||||
}
|
||||
|
||||
func recordResize(cols: Int, rows: Int) {
|
||||
guard isRecording else { return }
|
||||
|
||||
let currentTime = Date().timeIntervalSince1970
|
||||
let relativeTime = currentTime - startTime
|
||||
|
||||
let resizeData = "\(cols)x\(rows)"
|
||||
let event = CastEvent(
|
||||
time: relativeTime,
|
||||
type: "r", // resize
|
||||
data: resizeData
|
||||
)
|
||||
|
||||
events.append(event)
|
||||
}
|
||||
|
||||
func exportCastFile() -> Data? {
|
||||
// Create header
|
||||
let header = CastFile(
|
||||
version: 2,
|
||||
width: width,
|
||||
height: height,
|
||||
timestamp: startTime,
|
||||
title: "VibeTunnel Recording - \(sessionId)",
|
||||
env: ["TERM": "xterm-256color", "SHELL": "/bin/zsh"],
|
||||
theme: nil
|
||||
)
|
||||
|
||||
guard let headerData = try? JSONEncoder().encode(header),
|
||||
let headerString = String(data: headerData, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the cast file content
|
||||
var castContent = headerString + "\n"
|
||||
|
||||
// Add all events
|
||||
for event in events {
|
||||
// Cast events are encoded as arrays [time, type, data]
|
||||
let eventArray: [Any] = [event.time, event.type, event.data]
|
||||
|
||||
if let jsonData = try? JSONSerialization.data(withJSONObject: eventArray),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
castContent += jsonString + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return castContent.data(using: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
// Cast file player for imported recordings
|
||||
class CastPlayer {
|
||||
let header: CastFile
|
||||
let events: [CastEvent]
|
||||
|
||||
init?(data: Data) {
|
||||
guard let content = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
guard !lines.isEmpty else { return nil }
|
||||
|
||||
// Parse header (first line)
|
||||
guard let headerData = lines[0].data(using: .utf8),
|
||||
let header = try? JSONDecoder().decode(CastFile.self, from: headerData) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse events (remaining lines)
|
||||
var parsedEvents: [CastEvent] = []
|
||||
for i in 1..<lines.count {
|
||||
let line = lines[i].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !line.isEmpty,
|
||||
let lineData = line.data(using: .utf8),
|
||||
let array = try? JSONSerialization.jsonObject(with: lineData) as? [Any],
|
||||
array.count >= 3,
|
||||
let time = array[0] as? Double,
|
||||
let type = array[1] as? String,
|
||||
let data = array[2] as? String else {
|
||||
continue
|
||||
}
|
||||
|
||||
let event = CastEvent(time: time, type: type, data: data)
|
||||
parsedEvents.append(event)
|
||||
}
|
||||
|
||||
self.header = header
|
||||
self.events = parsedEvents
|
||||
}
|
||||
|
||||
var duration: TimeInterval {
|
||||
events.last?.time ?? 0
|
||||
}
|
||||
|
||||
func play(onEvent: @escaping @Sendable (CastEvent) -> Void, completion: @escaping @Sendable () -> Void) {
|
||||
let eventsToPlay = self.events
|
||||
Task { @Sendable in
|
||||
for event in eventsToPlay {
|
||||
// Wait for the appropriate time
|
||||
if event.time > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(event.time * 1_000_000_000))
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
onEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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]?
|
||||
}
|
||||
|
||||
|
|
|
|||
54
ios/VibeTunnel/Models/TerminalSnapshot.swift
Normal file
54
ios/VibeTunnel/Models/TerminalSnapshot.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import Foundation
|
||||
|
||||
struct TerminalSnapshot: Codable {
|
||||
let sessionId: String
|
||||
let header: AsciinemaHeader?
|
||||
let events: [AsciinemaEvent]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case sessionId = "session_id"
|
||||
case header
|
||||
case events
|
||||
}
|
||||
}
|
||||
|
||||
struct AsciinemaEvent: Codable {
|
||||
let time: Double
|
||||
let type: EventType
|
||||
let data: String
|
||||
|
||||
enum EventType: String, Codable {
|
||||
case output = "o"
|
||||
case input = "i"
|
||||
case resize = "r"
|
||||
case marker = "m"
|
||||
}
|
||||
}
|
||||
|
||||
extension TerminalSnapshot {
|
||||
// Get the last few lines of terminal output for preview
|
||||
var outputPreview: String {
|
||||
// Combine all output events
|
||||
let outputEvents = events.filter { $0.type == .output }
|
||||
let combinedOutput = outputEvents.map { $0.data }.joined()
|
||||
|
||||
// Split into lines and get last few non-empty lines
|
||||
let lines = combinedOutput.components(separatedBy: .newlines)
|
||||
let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
|
||||
// Take last 3-5 lines for preview
|
||||
let previewLines = Array(nonEmptyLines.suffix(4))
|
||||
return previewLines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// Get a cleaned version without ANSI escape codes (basic implementation)
|
||||
var cleanOutputPreview: String {
|
||||
let output = outputPreview
|
||||
// Remove common ANSI escape sequences (this is a simplified version)
|
||||
let pattern = "\\x1B\\[[0-9;]*[mGKHf]"
|
||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||
let range = NSRange(location: 0, length: output.utf16.count)
|
||||
let cleaned = regex?.stringByReplacingMatches(in: output, options: [], range: range, withTemplate: "") ?? output
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
|
|
@ -2,73 +2,120 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>VibeTunnel</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>TerminalBackground</string>
|
||||
<key>UIImageName</key>
|
||||
<string>LaunchIcon</string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>FiraCode-Regular.ttf</string>
|
||||
<string>FiraCode-Medium.ttf</string>
|
||||
<string>FiraCode-Bold.ttf</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleLightContent</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>VibeTunnel</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>TerminalBackground</string>
|
||||
<key>UIImageName</key>
|
||||
<string>LaunchIcon</string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>FiraCode-Regular.ttf</string>
|
||||
<string>FiraCode-Medium.ttf</string>
|
||||
<string>FiraCode-Bold.ttf</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<false/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleLightContent</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>vibetunnel</string>
|
||||
</array>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.vibetunnel.app</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Asciinema Recording</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Default</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.cast</string>
|
||||
<string>public.plain-text</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>public.cast</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Asciinema Recording</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.plain-text</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>cast</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
340
ios/VibeTunnel/Services/BufferWebSocketClient.swift
Normal file
340
ios/VibeTunnel/Services/BufferWebSocketClient.swift
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
|
||||
// Terminal event types that match the server's output
|
||||
enum TerminalWebSocketEvent {
|
||||
case header(width: Int, height: Int)
|
||||
case output(timestamp: Double, data: String)
|
||||
case resize(timestamp: Double, dimensions: String)
|
||||
case exit(code: Int)
|
||||
}
|
||||
|
||||
enum WebSocketError: Error {
|
||||
case invalidURL
|
||||
case connectionFailed
|
||||
case invalidData
|
||||
case invalidMagicByte
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class BufferWebSocketClient: NSObject {
|
||||
// Magic byte for binary messages
|
||||
private static let BUFFER_MAGIC_BYTE: UInt8 = 0xbf
|
||||
|
||||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private let session = URLSession(configuration: .default)
|
||||
private var subscriptions = [String: ((TerminalWebSocketEvent) -> Void)]()
|
||||
private var reconnectTimer: Timer?
|
||||
private var reconnectAttempts = 0
|
||||
private var isConnecting = false
|
||||
private var pingTimer: Timer?
|
||||
|
||||
// Published events
|
||||
@Published private(set) var isConnected = false
|
||||
@Published private(set) var connectionError: Error?
|
||||
|
||||
private var baseURL: URL? {
|
||||
guard let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config) else {
|
||||
return nil
|
||||
}
|
||||
return serverConfig.baseURL
|
||||
}
|
||||
|
||||
func connect() {
|
||||
guard !isConnecting else { return }
|
||||
guard let baseURL = baseURL else {
|
||||
connectionError = WebSocketError.invalidURL
|
||||
return
|
||||
}
|
||||
|
||||
isConnecting = true
|
||||
connectionError = nil
|
||||
|
||||
// Convert HTTP URL to WebSocket URL
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
||||
components?.scheme = baseURL.scheme == "https" ? "wss" : "ws"
|
||||
components?.path = "/buffers"
|
||||
|
||||
guard let wsURL = components?.url else {
|
||||
connectionError = WebSocketError.invalidURL
|
||||
isConnecting = false
|
||||
return
|
||||
}
|
||||
|
||||
print("[BufferWebSocket] Connecting to \(wsURL)")
|
||||
|
||||
// Cancel existing task if any
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
|
||||
// Create request with authentication
|
||||
var request = URLRequest(url: wsURL)
|
||||
|
||||
// Add authentication header if needed
|
||||
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
let serverConfig = try? JSONDecoder().decode(ServerConfig.self, from: config),
|
||||
let authHeader = serverConfig.authorizationHeader {
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
// Create new WebSocket task
|
||||
webSocketTask = session.webSocketTask(with: request)
|
||||
webSocketTask?.resume()
|
||||
|
||||
// Start receiving messages
|
||||
receiveMessage()
|
||||
|
||||
// Send initial ping to establish connection
|
||||
Task {
|
||||
do {
|
||||
try await sendPing()
|
||||
isConnected = true
|
||||
isConnecting = false
|
||||
reconnectAttempts = 0
|
||||
startPingTimer()
|
||||
|
||||
// Re-subscribe to all sessions
|
||||
for sessionId in subscriptions.keys {
|
||||
try await subscribe(to: sessionId)
|
||||
}
|
||||
} catch {
|
||||
print("[BufferWebSocket] Connection failed: \(error)")
|
||||
connectionError = error
|
||||
isConnecting = false
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveMessage() {
|
||||
webSocketTask?.receive { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
Task { @MainActor in
|
||||
self.handleMessage(message)
|
||||
self.receiveMessage() // Continue receiving
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
print("[BufferWebSocket] Receive error: \(error)")
|
||||
Task { @MainActor in
|
||||
self.handleDisconnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMessage(_ message: URLSessionWebSocketTask.Message) {
|
||||
switch message {
|
||||
case .data(let data):
|
||||
handleBinaryMessage(data)
|
||||
|
||||
case .string(let text):
|
||||
handleTextMessage(text)
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTextMessage(_ text: String) {
|
||||
guard let data = text.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
|
||||
if let type = json["type"] as? String {
|
||||
switch type {
|
||||
case "ping":
|
||||
// Respond with pong
|
||||
Task {
|
||||
try? await sendMessage(["type": "pong"])
|
||||
}
|
||||
|
||||
case "error":
|
||||
if let message = json["message"] as? String {
|
||||
print("[BufferWebSocket] Server error: \(message)")
|
||||
}
|
||||
|
||||
default:
|
||||
print("[BufferWebSocket] Unknown message type: \(type)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBinaryMessage(_ data: Data) {
|
||||
guard data.count > 5 else { return }
|
||||
|
||||
var offset = 0
|
||||
|
||||
// Check magic byte
|
||||
let magic = data[offset]
|
||||
offset += 1
|
||||
|
||||
guard magic == Self.BUFFER_MAGIC_BYTE else {
|
||||
print("[BufferWebSocket] Invalid magic byte: \(magic)")
|
||||
return
|
||||
}
|
||||
|
||||
// Read session ID length (4 bytes, little endian)
|
||||
let sessionIdLength = data.withUnsafeBytes { bytes in
|
||||
bytes.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||
}
|
||||
offset += 4
|
||||
|
||||
// Read session ID
|
||||
guard data.count >= offset + Int(sessionIdLength) else { return }
|
||||
let sessionIdData = data.subdata(in: offset..<(offset + Int(sessionIdLength)))
|
||||
guard let sessionId = String(data: sessionIdData, encoding: .utf8) else { return }
|
||||
offset += Int(sessionIdLength)
|
||||
|
||||
// Remaining data is the message payload
|
||||
let messageData = data.subdata(in: offset..<data.count)
|
||||
|
||||
// Decode terminal event
|
||||
if let event = decodeTerminalEvent(from: messageData),
|
||||
let handler = subscriptions[sessionId] {
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeTerminalEvent(from data: Data) -> TerminalWebSocketEvent? {
|
||||
// Decode the JSON payload from the binary message
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = json["type"] as? String {
|
||||
|
||||
switch type {
|
||||
case "header":
|
||||
if let width = json["width"] as? Int,
|
||||
let height = json["height"] as? Int {
|
||||
return .header(width: width, height: height)
|
||||
}
|
||||
|
||||
case "output":
|
||||
if let timestamp = json["timestamp"] as? Double,
|
||||
let outputData = json["data"] as? String {
|
||||
return .output(timestamp: timestamp, data: outputData)
|
||||
}
|
||||
|
||||
case "resize":
|
||||
if let timestamp = json["timestamp"] as? Double,
|
||||
let dimensions = json["dimensions"] as? String {
|
||||
return .resize(timestamp: timestamp, dimensions: dimensions)
|
||||
}
|
||||
|
||||
case "exit":
|
||||
let code = json["code"] as? Int ?? 0
|
||||
return .exit(code: code)
|
||||
|
||||
default:
|
||||
print("[BufferWebSocket] Unknown message type: \(type)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("[BufferWebSocket] Failed to decode message: \(error)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func subscribe(to sessionId: String, handler: @escaping (TerminalWebSocketEvent) -> Void) {
|
||||
subscriptions[sessionId] = handler
|
||||
|
||||
Task {
|
||||
try? await subscribe(to: sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private func subscribe(to sessionId: String) async throws {
|
||||
try await sendMessage(["type": "subscribe", "sessionId": sessionId])
|
||||
}
|
||||
|
||||
func unsubscribe(from sessionId: String) {
|
||||
subscriptions.removeValue(forKey: sessionId)
|
||||
|
||||
Task {
|
||||
try? await sendMessage(["type": "unsubscribe", "sessionId": sessionId])
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessage(_ message: [String: Any]) async throws {
|
||||
guard let webSocketTask = webSocketTask else {
|
||||
throw WebSocketError.connectionFailed
|
||||
}
|
||||
|
||||
let data = try JSONSerialization.data(withJSONObject: message)
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw WebSocketError.invalidData
|
||||
}
|
||||
|
||||
try await webSocketTask.send(.string(string))
|
||||
}
|
||||
|
||||
private func sendPing() async throws {
|
||||
try await sendMessage(["type": "ping"])
|
||||
}
|
||||
|
||||
private func startPingTimer() {
|
||||
stopPingTimer()
|
||||
|
||||
pingTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { _ in
|
||||
Task { [weak self] in
|
||||
try? await self?.sendPing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPingTimer() {
|
||||
pingTimer?.invalidate()
|
||||
pingTimer = nil
|
||||
}
|
||||
|
||||
private func handleDisconnection() {
|
||||
isConnected = false
|
||||
webSocketTask = nil
|
||||
stopPingTimer()
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
guard reconnectTimer == nil else { return }
|
||||
|
||||
let delay = min(pow(2.0, Double(reconnectAttempts)), 30.0)
|
||||
reconnectAttempts += 1
|
||||
|
||||
print("[BufferWebSocket] Reconnecting in \(delay)s (attempt \(reconnectAttempts))")
|
||||
|
||||
reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.reconnectTimer = nil
|
||||
self?.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
reconnectTimer?.invalidate()
|
||||
reconnectTimer = nil
|
||||
stopPingTimer()
|
||||
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
|
||||
subscriptions.removeAll()
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
deinit {
|
||||
reconnectTimer?.invalidate()
|
||||
reconnectTimer = nil
|
||||
pingTimer?.invalidate()
|
||||
pingTimer = nil
|
||||
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
|
||||
subscriptions.removeAll()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
final class SSEClient: NSObject, @unchecked Sendable {
|
||||
private var eventSource: URLSessionDataTask?
|
||||
private var session: URLSession?
|
||||
private var streamContinuation: AsyncStream<TerminalEvent>.Continuation?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.timeoutIntervalForRequest = TimeInterval.infinity
|
||||
configuration.timeoutIntervalForResource = TimeInterval.infinity
|
||||
self.session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
||||
}
|
||||
|
||||
func connect(to url: URL) -> AsyncStream<TerminalEvent> {
|
||||
disconnect()
|
||||
|
||||
return AsyncStream { continuation in
|
||||
self.streamContinuation = continuation
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
||||
request.timeoutInterval = TimeInterval.infinity
|
||||
|
||||
self.eventSource = self.session?.dataTask(with: request)
|
||||
self.eventSource?.resume()
|
||||
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
self.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
eventSource?.cancel()
|
||||
eventSource = nil
|
||||
streamContinuation?.finish()
|
||||
streamContinuation = nil
|
||||
}
|
||||
|
||||
private var dataBuffer = Data()
|
||||
|
||||
private func processSSEData(_ data: Data) {
|
||||
dataBuffer.append(data)
|
||||
|
||||
// Process complete lines
|
||||
while let newlineRange = dataBuffer.range(of: Data("\n".utf8)) {
|
||||
let lineData = dataBuffer.subdata(in: 0..<newlineRange.lowerBound)
|
||||
dataBuffer.removeSubrange(0..<newlineRange.upperBound)
|
||||
|
||||
guard let line = String(data: lineData, encoding: .utf8) else { continue }
|
||||
processSSELine(line)
|
||||
}
|
||||
}
|
||||
|
||||
private func processSSELine(_ line: String) {
|
||||
let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Skip empty lines and comments
|
||||
if trimmedLine.isEmpty || trimmedLine.hasPrefix(":") {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle event data
|
||||
if trimmedLine.hasPrefix("data: ") {
|
||||
let data = String(trimmedLine.dropFirst(6))
|
||||
|
||||
// Parse terminal event
|
||||
if let event = TerminalEvent(from: data) {
|
||||
streamContinuation?.yield(event)
|
||||
}
|
||||
}
|
||||
// Handle special events
|
||||
else if trimmedLine.hasPrefix("event: ") {
|
||||
let eventType = String(trimmedLine.dropFirst(7))
|
||||
handleSpecialEvent(eventType)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSpecialEvent(_ eventType: String) {
|
||||
switch eventType {
|
||||
case "exit", "end":
|
||||
streamContinuation?.finish()
|
||||
case "error":
|
||||
// Could parse error data if needed
|
||||
streamContinuation?.finish()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDataDelegate
|
||||
|
||||
extension SSEClient: URLSessionDataDelegate {
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
processSSEData(data)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
print("SSE connection error: \(error)")
|
||||
}
|
||||
streamContinuation?.finish()
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
completionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
completionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
374
ios/VibeTunnel/Views/Terminal/CastPlayerView.swift
Normal file
374
ios/VibeTunnel/Views/Terminal/CastPlayerView.swift
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import SwiftUI
|
||||
import SwiftTerm
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct CastPlayerView: View {
|
||||
let castFileURL: URL
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@StateObject private var viewModel = CastPlayerViewModel()
|
||||
@State private var fontSize: CGFloat = 14
|
||||
@State private var isPlaying = false
|
||||
@State private var currentTime: TimeInterval = 0
|
||||
@State private var playbackSpeed: Double = 1.0
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
Theme.Colors.terminalBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.isLoading {
|
||||
loadingView
|
||||
} else if let error = viewModel.errorMessage {
|
||||
errorView(error)
|
||||
} else if viewModel.player != nil {
|
||||
playerContent
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Recording Playback")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
viewModel.loadCastFile(from: castFileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||||
.scaleEffect(1.5)
|
||||
|
||||
Text("Loading recording...")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func errorView(_ error: String) -> some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(Theme.Colors.errorAccent)
|
||||
|
||||
Text("Failed to load recording")
|
||||
.font(.headline)
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
Text(error)
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var playerContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Terminal display
|
||||
CastTerminalView(fontSize: $fontSize, viewModel: viewModel)
|
||||
.background(Theme.Colors.terminalBackground)
|
||||
|
||||
// Playback controls
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
// Progress bar
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
Slider(value: $currentTime, in: 0...viewModel.duration) { editing in
|
||||
if !editing && isPlaying {
|
||||
// Resume playback from new position
|
||||
viewModel.seekTo(time: currentTime)
|
||||
}
|
||||
}
|
||||
.accentColor(Theme.Colors.primaryAccent)
|
||||
|
||||
HStack {
|
||||
Text(formatTime(currentTime))
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
Spacer()
|
||||
Text(formatTime(viewModel.duration))
|
||||
.font(Theme.Typography.terminalSystem(size: 10))
|
||||
}
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
}
|
||||
|
||||
// Control buttons
|
||||
HStack(spacing: Theme.Spacing.xl) {
|
||||
// Speed control
|
||||
Menu {
|
||||
Button("0.5x") { playbackSpeed = 0.5 }
|
||||
Button("1x") { playbackSpeed = 1.0 }
|
||||
Button("2x") { playbackSpeed = 2.0 }
|
||||
Button("4x") { playbackSpeed = 4.0 }
|
||||
} label: {
|
||||
Text("\(playbackSpeed, specifier: "%.1f")x")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(Theme.Colors.primaryAccent, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// Play/Pause
|
||||
Button(action: togglePlayback) {
|
||||
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
|
||||
// Restart
|
||||
Button(action: restart) {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Theme.Colors.cardBackground)
|
||||
}
|
||||
.onReceive(viewModel.$currentTime) { time in
|
||||
if !viewModel.isSeeking {
|
||||
currentTime = time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePlayback() {
|
||||
if isPlaying {
|
||||
viewModel.pause()
|
||||
} else {
|
||||
viewModel.play(speed: playbackSpeed)
|
||||
}
|
||||
isPlaying.toggle()
|
||||
}
|
||||
|
||||
private func restart() {
|
||||
viewModel.restart()
|
||||
currentTime = 0
|
||||
if isPlaying {
|
||||
viewModel.play(speed: playbackSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||
let minutes = Int(seconds) / 60
|
||||
let remainingSeconds = Int(seconds) % 60
|
||||
return String(format: "%d:%02d", minutes, remainingSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// Simple terminal view for cast playback
|
||||
struct CastTerminalView: UIViewRepresentable {
|
||||
@Binding var fontSize: CGFloat
|
||||
@ObservedObject var viewModel: CastPlayerViewModel
|
||||
|
||||
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
||||
let terminal = SwiftTerm.TerminalView()
|
||||
|
||||
terminal.backgroundColor = UIColor(Theme.Colors.terminalBackground)
|
||||
terminal.nativeForegroundColor = UIColor(Theme.Colors.terminalForeground)
|
||||
terminal.nativeBackgroundColor = UIColor(Theme.Colors.terminalBackground)
|
||||
|
||||
terminal.allowMouseReporting = false
|
||||
// TODO: Check SwiftTerm API for link detection
|
||||
// terminal.linkRecognizer = .autodetect
|
||||
|
||||
updateFont(terminal, size: fontSize)
|
||||
|
||||
// Set initial size from cast file if available
|
||||
if let header = viewModel.header {
|
||||
terminal.resize(cols: Int(header.width), rows: Int(header.height))
|
||||
} else {
|
||||
terminal.resize(cols: 80, rows: 24)
|
||||
}
|
||||
|
||||
context.coordinator.terminal = terminal
|
||||
return terminal
|
||||
}
|
||||
|
||||
func updateUIView(_ terminal: SwiftTerm.TerminalView, context: Context) {
|
||||
updateFont(terminal, size: fontSize)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
private func updateFont(_ terminal: SwiftTerm.TerminalView, size: CGFloat) {
|
||||
let font: UIFont
|
||||
if let customFont = UIFont(name: Theme.Typography.terminalFont, size: size) {
|
||||
font = customFont
|
||||
} else if let fallbackFont = UIFont(name: Theme.Typography.terminalFontFallback, size: size) {
|
||||
font = fallbackFont
|
||||
} else {
|
||||
font = UIFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
}
|
||||
terminal.font = font
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class Coordinator: NSObject {
|
||||
weak var terminal: SwiftTerm.TerminalView?
|
||||
let viewModel: CastPlayerViewModel
|
||||
|
||||
init(viewModel: CastPlayerViewModel) {
|
||||
self.viewModel = viewModel
|
||||
super.init()
|
||||
|
||||
// Set up terminal output handler
|
||||
viewModel.onTerminalOutput = { [weak self] data in
|
||||
Task { @MainActor in
|
||||
self?.terminal?.feed(text: data)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.onTerminalClear = { [weak self] in
|
||||
Task { @MainActor in
|
||||
// TODO: Check SwiftTerm API for clearing terminal
|
||||
// For now, we'll feed a clear screen sequence
|
||||
self?.terminal?.feed(text: "\u{001B}[2J\u{001B}[H")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class CastPlayerViewModel: ObservableObject {
|
||||
@Published var isLoading = true
|
||||
@Published var errorMessage: String?
|
||||
@Published var currentTime: TimeInterval = 0
|
||||
@Published var isSeeking = false
|
||||
|
||||
var player: CastPlayer?
|
||||
var header: CastFile? { player?.header }
|
||||
var duration: TimeInterval { player?.duration ?? 0 }
|
||||
|
||||
var onTerminalOutput: ((String) -> Void)?
|
||||
var onTerminalClear: (() -> Void)?
|
||||
|
||||
private var playbackTask: Task<Void, Never>?
|
||||
|
||||
func loadCastFile(from url: URL) {
|
||||
Task {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
|
||||
guard let player = CastPlayer(data: data) else {
|
||||
errorMessage = "Invalid cast file format"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
self.player = player
|
||||
isLoading = false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func play(speed: Double = 1.0) {
|
||||
playbackTask?.cancel()
|
||||
|
||||
playbackTask = Task {
|
||||
guard let player = player else { return }
|
||||
|
||||
player.play(from: currentTime, speed: speed) { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch event.type {
|
||||
case "o":
|
||||
self.onTerminalOutput?(event.data)
|
||||
case "r":
|
||||
// Handle resize if needed
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
self.currentTime = event.time
|
||||
} completion: {
|
||||
// Playback completed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
playbackTask?.cancel()
|
||||
}
|
||||
|
||||
func seekTo(time: TimeInterval) {
|
||||
isSeeking = true
|
||||
currentTime = time
|
||||
|
||||
// Clear terminal and replay up to the seek point
|
||||
onTerminalClear?()
|
||||
|
||||
guard let player = player else { return }
|
||||
|
||||
// Replay all events up to the seek time instantly
|
||||
for event in player.events where event.time <= time {
|
||||
if event.type == "o" {
|
||||
onTerminalOutput?(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
isSeeking = false
|
||||
}
|
||||
|
||||
func restart() {
|
||||
playbackTask?.cancel()
|
||||
currentTime = 0
|
||||
onTerminalClear?()
|
||||
}
|
||||
}
|
||||
|
||||
// Extension to CastPlayer for playback from specific time
|
||||
extension CastPlayer {
|
||||
func play(from startTime: TimeInterval = 0, speed: Double = 1.0, onEvent: @escaping @Sendable (CastEvent) -> Void, completion: @escaping @Sendable () -> Void) {
|
||||
let eventsToPlay = events.filter { $0.time > startTime }
|
||||
Task { @Sendable in
|
||||
var lastEventTime = startTime
|
||||
|
||||
for event in eventsToPlay {
|
||||
// Calculate wait time adjusted for playback speed
|
||||
let waitTime = (event.time - lastEventTime) / speed
|
||||
if waitTime > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
|
||||
}
|
||||
|
||||
// Check if task was cancelled
|
||||
if Task.isCancelled { break }
|
||||
|
||||
await MainActor.run {
|
||||
onEvent(event)
|
||||
}
|
||||
|
||||
lastEventTime = event.time
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
153
ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift
Normal file
153
ios/VibeTunnel/Views/Terminal/RecordingExportSheet.swift
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct RecordingExportSheet: View {
|
||||
@ObservedObject var recorder: CastRecorder
|
||||
let sessionName: String
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var isExporting = false
|
||||
@State private var showingShareSheet = false
|
||||
@State private var exportedFileURL: URL?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: Theme.Spacing.xl) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.Colors.primaryAccent.opacity(0.1))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "record.circle.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
.padding(.top, Theme.Spacing.xl)
|
||||
|
||||
// Info
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text("Recording Export")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(Theme.Colors.terminalForeground)
|
||||
|
||||
if recorder.isRecording {
|
||||
Text("Recording in progress...")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
} else if !recorder.events.isEmpty {
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
Text("\(recorder.events.count) events recorded")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
|
||||
if let duration = recorder.events.last?.time {
|
||||
Text("Duration: \(formatDuration(duration))")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No recording available")
|
||||
.font(Theme.Typography.terminalSystem(size: 14))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Export button
|
||||
if !recorder.events.isEmpty {
|
||||
Button(action: exportRecording) {
|
||||
if isExporting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.terminalBackground))
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
Text("Export as .cast file")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(Theme.Typography.terminalSystem(size: 16))
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Theme.Colors.terminalBackground)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.md)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(Theme.Colors.primaryAccent)
|
||||
)
|
||||
.disabled(isExporting)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(Theme.Colors.terminalBackground)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingShareSheet) {
|
||||
if let url = exportedFileURL {
|
||||
ShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportRecording() {
|
||||
isExporting = true
|
||||
|
||||
Task {
|
||||
if let castData = recorder.exportCastFile() {
|
||||
// Create temporary file
|
||||
let fileName = "\(sessionName.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).cast"
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||||
|
||||
do {
|
||||
try castData.write(to: tempURL)
|
||||
|
||||
await MainActor.run {
|
||||
exportedFileURL = tempURL
|
||||
isExporting = false
|
||||
showingShareSheet = true
|
||||
}
|
||||
} catch {
|
||||
print("Failed to save cast file: \(error)")
|
||||
await MainActor.run {
|
||||
isExporting = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
isExporting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDuration(_ seconds: TimeInterval) -> String {
|
||||
let minutes = Int(seconds) / 60
|
||||
let remainingSeconds = Int(seconds) % 60
|
||||
return String(format: "%d:%02d", minutes, remainingSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ struct TerminalView: View {
|
|||
@StateObject private var viewModel: TerminalViewModel
|
||||
@State private var fontSize: CGFloat = 14
|
||||
@State private var showingFontSizeSheet = false
|
||||
@State private var showingRecordingSheet = false
|
||||
@State private var keyboardHeight: CGFloat = 0
|
||||
@FocusState private var isInputFocused: Bool
|
||||
|
||||
|
|
@ -36,6 +37,9 @@ struct TerminalView: View {
|
|||
}
|
||||
.navigationTitle(session.displayName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(.visible, for: .bottomBar)
|
||||
.toolbarBackground(.visible, for: .bottomBar)
|
||||
.toolbarBackground(Theme.Colors.cardBackground, for: .bottomBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Close") {
|
||||
|
|
@ -57,6 +61,27 @@ struct TerminalView: View {
|
|||
Button(action: { viewModel.copyBuffer() }) {
|
||||
Label("Copy All", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if viewModel.castRecorder.isRecording {
|
||||
Button(action: {
|
||||
viewModel.stopRecording()
|
||||
showingRecordingSheet = true
|
||||
}) {
|
||||
Label("Stop Recording", systemImage: "stop.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Button(action: { viewModel.startRecording() }) {
|
||||
Label("Start Recording", systemImage: "record.circle")
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: { showingRecordingSheet = true }) {
|
||||
Label("Export Recording", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(viewModel.castRecorder.events.isEmpty)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.foregroundColor(Theme.Colors.primaryAccent)
|
||||
|
|
@ -66,6 +91,71 @@ struct TerminalView: View {
|
|||
.sheet(isPresented: $showingFontSizeSheet) {
|
||||
FontSizeSheet(fontSize: $fontSize)
|
||||
}
|
||||
.sheet(isPresented: $showingRecordingSheet) {
|
||||
RecordingExportSheet(recorder: viewModel.castRecorder, sessionName: session.displayName)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: "rectangle.split.3x1")
|
||||
.font(.caption)
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
Text("\(viewModel.terminalCols) × \(viewModel.terminalRows)")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Session status
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.3))
|
||||
.frame(width: 6, height: 6)
|
||||
Text(session.isRunning ? "Running" : "Exited")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground.opacity(0.5))
|
||||
}
|
||||
|
||||
if let pid = session.pid {
|
||||
Spacer()
|
||||
|
||||
Text("PID: \(pid)")
|
||||
.font(Theme.Typography.terminalSystem(size: 12))
|
||||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||||
.onTapGesture {
|
||||
UIPasteboard.general.string = String(pid)
|
||||
HapticFeedback.notification(.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recording indicator
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if viewModel.castRecorder.isRecording {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(Color.red)
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(
|
||||
Circle()
|
||||
.fill(Color.red.opacity(0.3))
|
||||
.frame(width: 16, height: 16)
|
||||
.scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0)
|
||||
.animation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true), value: viewModel.recordingPulse)
|
||||
)
|
||||
Text("REC")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.recordingPulse = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.preferredColorScheme(.dark)
|
||||
|
|
@ -137,6 +227,8 @@ struct TerminalView: View {
|
|||
viewModel.sendInput(text)
|
||||
},
|
||||
onResize: { cols, rows in
|
||||
viewModel.terminalCols = cols
|
||||
viewModel.terminalRows = rows
|
||||
viewModel.resize(cols: cols, rows: rows)
|
||||
},
|
||||
viewModel: viewModel
|
||||
|
|
@ -153,6 +245,9 @@ struct TerminalView: View {
|
|||
},
|
||||
onDismissKeyboard: {
|
||||
isInputFocused = false
|
||||
},
|
||||
onRawInput: { input in
|
||||
viewModel.sendInput(input)
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
|
|
@ -167,14 +262,20 @@ class TerminalViewModel: ObservableObject {
|
|||
@Published var isConnected = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var terminalViewId = UUID()
|
||||
@Published var terminalCols: Int = 0
|
||||
@Published var terminalRows: Int = 0
|
||||
@Published var isAutoScrollEnabled = true
|
||||
@Published var recordingPulse = false
|
||||
|
||||
let session: Session
|
||||
private var sseClient: SSEClient?
|
||||
let castRecorder: CastRecorder
|
||||
private var bufferWebSocketClient: BufferWebSocketClient?
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
weak var terminalCoordinator: TerminalHostingView.Coordinator?
|
||||
|
||||
init(session: Session) {
|
||||
self.session = session
|
||||
self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24)
|
||||
setupTerminal()
|
||||
}
|
||||
|
||||
|
|
@ -182,33 +283,58 @@ class TerminalViewModel: ObservableObject {
|
|||
// Terminal setup now handled by SimpleTerminalView
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
castRecorder.startRecording()
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
castRecorder.stopRecording()
|
||||
}
|
||||
|
||||
func connect() {
|
||||
isConnecting = true
|
||||
errorMessage = nil
|
||||
|
||||
guard let streamURL = APIClient.shared.streamURL(for: session.id) else {
|
||||
errorMessage = "Failed to create stream URL"
|
||||
isConnecting = false
|
||||
return
|
||||
// Create WebSocket client if needed
|
||||
if bufferWebSocketClient == nil {
|
||||
bufferWebSocketClient = BufferWebSocketClient()
|
||||
}
|
||||
|
||||
// Load existing terminal snapshot first if session is already running
|
||||
if session.isRunning {
|
||||
Task {
|
||||
await loadSnapshot()
|
||||
// Connect to WebSocket
|
||||
bufferWebSocketClient?.connect()
|
||||
|
||||
// Subscribe to terminal events
|
||||
bufferWebSocketClient?.subscribe(to: session.id) { [weak self] event in
|
||||
Task { @MainActor in
|
||||
self?.handleWebSocketEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
sseClient = SSEClient()
|
||||
|
||||
Task {
|
||||
for await event in sseClient!.connect(to: streamURL) {
|
||||
handleTerminalEvent(event)
|
||||
// Monitor connection status
|
||||
bufferWebSocketClient?.$isConnected
|
||||
.sink { [weak self] connected in
|
||||
Task { @MainActor in
|
||||
self?.isConnecting = false
|
||||
self?.isConnected = connected
|
||||
if !connected {
|
||||
self?.errorMessage = "WebSocket disconnected"
|
||||
} else {
|
||||
self?.errorMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
isConnecting = false
|
||||
isConnected = true
|
||||
// Monitor connection errors
|
||||
bufferWebSocketClient?.$connectionError
|
||||
.compactMap { $0 }
|
||||
.sink { [weak self] error in
|
||||
Task { @MainActor in
|
||||
self?.errorMessage = error.localizedDescription
|
||||
self?.isConnecting = false
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -227,39 +353,52 @@ class TerminalViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
func disconnect() {
|
||||
sseClient?.disconnect()
|
||||
sseClient = nil
|
||||
bufferWebSocketClient?.unsubscribe(from: session.id)
|
||||
bufferWebSocketClient?.disconnect()
|
||||
bufferWebSocketClient = nil
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleTerminalEvent(_ event: TerminalEvent) {
|
||||
private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) {
|
||||
switch event {
|
||||
case .header(let header):
|
||||
case .header(let width, let height):
|
||||
// Initial terminal setup
|
||||
print("Terminal initialized: \(header.width)x\(header.height)")
|
||||
print("Terminal initialized: \(width)x\(height)")
|
||||
terminalCols = width
|
||||
terminalRows = height
|
||||
// The terminal will be resized when created
|
||||
|
||||
case .output(_, let data):
|
||||
case .output(let timestamp, let data):
|
||||
// Feed output data directly to the terminal
|
||||
terminalCoordinator?.feedData(data)
|
||||
// Record output if recording
|
||||
castRecorder.recordOutput(data)
|
||||
|
||||
case .resize(_, let dimensions):
|
||||
case .resize(let timestamp, let dimensions):
|
||||
// Parse dimensions like "120x30"
|
||||
let parts = dimensions.split(separator: "x")
|
||||
if parts.count == 2,
|
||||
let cols = Int(parts[0]),
|
||||
let rows = Int(parts[1]) {
|
||||
// Handle resize if needed
|
||||
// Update terminal dimensions
|
||||
terminalCols = cols
|
||||
terminalRows = rows
|
||||
print("Terminal resize: \(cols)x\(rows)")
|
||||
// Record resize event
|
||||
castRecorder.recordResize(cols: cols, rows: rows)
|
||||
}
|
||||
|
||||
case .exit(let code, _):
|
||||
case .exit(let code):
|
||||
// Session has exited
|
||||
isConnected = false
|
||||
if code != 0 {
|
||||
errorMessage = "Session exited with code \(code)"
|
||||
}
|
||||
// Stop recording if active
|
||||
if castRecorder.isRecording {
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue