From 9c4454b45449a36c19bf5cef16976bcc7e0cdca2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Jun 2025 22:33:58 +0200 Subject: [PATCH] Convert to menu bar only app - Remove ContentView window - app now launches with settings dialog only - Add status bar item (menu bar icon) with network shield symbol - Configure as LSUIElement (background app) by default - Menu bar menu includes Settings, About, and Quit options - Auto-show settings on first launch when in menu bar mode - Maintain Show in Dock toggle for users who prefer dock visibility --- README.md | 2 +- VibeTunnel.xcodeproj/project.pbxproj | 2 +- .../UserInterfaceState.xcuserstate | Bin 23455 -> 24947 bytes .../menubar.iconset/icon_16x16.png | Bin 0 -> 897 bytes .../menubar.iconset/icon_32x32.png | Bin 0 -> 1224 bytes VibeTunnel/ContentView.swift | 24 -- VibeTunnel/Core/Models/TunnelSession.swift | 72 ++++ .../Services/AuthenticationMiddleware.swift | 107 +++++ .../Core/Services/SparkleUpdaterManager.swift | 126 +++--- .../Core/Services/TerminalManager.swift | 163 ++++++++ VibeTunnel/Core/Services/TunnelServer.swift | 392 +++++++++--------- .../Core/Services/WebSocketHandler.swift | 196 +++++++++ VibeTunnel/Info.plist | 2 + VibeTunnel/VibeTunnelApp.swift | 72 +++- 14 files changed, 853 insertions(+), 305 deletions(-) create mode 100644 VibeTunnel/Assets.xcassets/menubar.iconset/icon_16x16.png create mode 100644 VibeTunnel/Assets.xcassets/menubar.iconset/icon_32x32.png delete mode 100644 VibeTunnel/ContentView.swift create mode 100644 VibeTunnel/Core/Models/TunnelSession.swift create mode 100644 VibeTunnel/Core/Services/AuthenticationMiddleware.swift create mode 100644 VibeTunnel/Core/Services/TerminalManager.swift create mode 100644 VibeTunnel/Core/Services/WebSocketHandler.swift diff --git a/README.md b/README.md index 35b19970..ccbc504a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # VibeTunnel -A macOS application for remotely controlling Claude Code and other terminal applications through a secure tunnel interface. +VibeTunnel is a Mac app that proxies terminal apps to the web. Now you can use Claude Code anywhere, anytime. Control open instances, read the output, type new commands or even open new instances. Supports macOS 14+. ## Overview diff --git a/VibeTunnel.xcodeproj/project.pbxproj b/VibeTunnel.xcodeproj/project.pbxproj index d07921c0..d3d2a69c 100644 --- a/VibeTunnel.xcodeproj/project.pbxproj +++ b/VibeTunnel.xcodeproj/project.pbxproj @@ -7,8 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 788688552DFF5EAB00B22C15 /* Hummingbird in Frameworks */ = {isa = PBXBuildFile; productRef = 788688212DFF600100B22C15 /* Hummingbird */; }; 788688322DFF700200B22C15 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 788688312DFF700100B22C15 /* Sparkle */; }; + 788688552DFF5EAB00B22C15 /* Hummingbird in Frameworks */ = {isa = PBXBuildFile; productRef = 788688212DFF600100B22C15 /* Hummingbird */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ diff --git a/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate b/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate index 6cf8ee0cd341a2278e02a24f8ab1ef6c50f7399f..271ecb98866831b5b89b4f1afd86a214ae105309 100644 GIT binary patch delta 13780 zcmaJ{2SC%u_rJUE_j>_lB(>9J_etH&%i$L71$3BfP>%=I0{aJGvF*Z2Yv)UfuF%8@C*1ATn2xD zo8T7s6Wjw2E#NQc0$rgPN}wBbhaS)qdO>d}g8?uYM#C5w3zbj>)i4ceVLHr!nXmvB z!mh9z>;wD4ey|Lh;UG8!4u#dQ5srW(;W%i41ilTY!0B)aTnd-L<**4h!xp##u7n@K zjqqc*34R6-!V}PP4qkxY!=K?L_#3xnjKI(ybqF$&s>VqoLU{r;MprL3usz;*` zkKRHP&~!8d%|tC|1zKrAtI%q+2CYTw(0cR%+K4_uThT7G8y!Fg(IIpNT}8j6Yv?+< zf&M@@(Jgcv-9dk%yXYQz#y|!!m=Q7U87D@}NElDXi%~GaOb8RogfU&2o=h*MAJd;1 z$dogc%wVRP8O97}>Y0(uC}te<216JN&%Dh{Vx}_hGBcT3%v@$3^B(g)vy@rJv@k1} zHOyLO1M?xXiP_9-VYV_mna`O$%mL;gbCfyBoMpaYzGE&izc5#r>&z|YKJ$Qi$~_YZEb_u(jUBRwoSFs>2hfdyc)p{={Boe`l|;f3kPkd+c-e1qV3DahxM3<$O6m&YzQUmHe1X;_POI16WE z6E4I>xEPnMr#Ix{Rybv$N%WyN^inrnI zcn98zKgYZ9Zu|w_gLQlHmv|rk3h&1U@ELp-pTp&NU27`-`@%YmJ;LDX810#ZR5(1LW3 z0WyIOWDyB*Bksh5coHw-O?-%SEeHb!U<4+R19Cwg2muAemH3iel1Dm|F607nCf|~1 z>jB(o>D#@^JW6jKIHI!BTwf{{=jsh5gKI~1YpklSYOFFhl#1O8&BJQz$C&EN>ISz~ zJOiA83vdNJL4Odn67&MSK_Ac;^do-6pU6l6k*@@$U;ros13@_nBr!xovPd?GE)@s% zEU&FF$IyQAs~TghGew?roI;pVF`coK7Z+1dTiIZ37KN!=yBG@UK-3CQ4TgalP)mY{ zf&`P06<|212Mu622_@kqf__F@rhYx!~#b{bd!0X%riD$ZB1^gk1ude*vpS?cc-P^sgfq4RN7j) zt(cbj$(q~ymx^8g?#SK^q&4L)YaUp%!&=y`(%A~;f%oYSvI%?wwpsVID{d#4V-B#E zio>k4+Iw2n?cE*b(n=Fr3kJ3yEfVT101K@@xA#e0AlR%Gg!jQR0fa?hF<1hYl31c7 zDxwxZXadbP5aLMuOAtz_N&Td)E!Tq$g26u^2~FTblK9f#o2bD*d1>$@>j~##Zj)H# z{g*Y&rN5=MxmSMzkxzF3RaIz#b4n(kYiSiH?3!Zh4f~ zR#%tRHJB@ME6g>GRfDR`^_0O@{wjGAb}yrF=6gaGW|x9Tk|A zq0bhe}zy+cw24ZZXEAKn-JzaGsk|WqAl&&v-YlV}^+TRJY zwq^FbeOT+li?uDh;LN&AApB|7`y2QjL^Xpe;3~-{1p0C$^(N!Z7J-!}H(%;7d|uhcz)jNrl^gD2oAcm|$>7Z5-Q5h*4mq$}x0x|1HH zC+S6cuZ1k+Aci9703BgF*d98OKBSTiCRJoS*+$Nh^Mv_^j$^sl!+P9xll4Pah1DVs zYULgDfxaNB2}()dCg?}{(VCyjr5Y#)N19i(klGY_K#?p(zZK$ZC1Paca^4i2;98CI`0u5D=Ld;abAQDEGEp6TE zK6Ulxvf#{Kb{%3M2Irv;X2ERW3iZ$cjnD*hU@pvq`P3upYs*Irqr9@FNJ`s}Btywq z@+KKVMp#!!G*w05&I;HGc7|PGF{viQ$Z*m?ifC`N#fX7r^)XfDwKWaq#xt-x>_OG{ zguP(z-hZ!-Zn=U#BQ>O!)X`SD23ofvW^{R(?LT7H1}^LmORYM$AZ7sdqB=LZrB&NN zicxP;PjRVh;|p7*l{RVPKcr%KmX2c>tbw(#4i1O)u%TCD)iCNX%ZAnUsdzcrZp8(C z>&?|=ja4JfovGg?V+bIl$mmkBVNjLzesH#hdfb@Uf6UT8+EI{J^UKZ3&CV`sEUT`q zY_(uPZ8=p~Thju^z_Eho`~NCY*P!p-^`A{o)3Lt+--P2`dkfn^Nn?FgO(l7QjI*9{ z*ZNZb33>QdAKH+50o%Y^-~>34nk~X3P;jqs5`4$n$s?MXOv@}Ds{Epw_! z2G-a}RS$oK!zoYZ5n$)S6?pZ(foz7q3k>)#jL*R9RM`#6&71HRb;>aV%Nopm=(nAa z4kOK!iwnsD>+Rs2ID0pL!n@syN+@ZY;h%z9X?g9in6kRMYI96QV~lMvQR)d?EEPvs z`}+9zv#qq3{A_hY4-o)SYv5n-5qu1vz^CvTd=6ib#bgOtN|ur3q=__>mNf_>gc!sk z4q+rh4rB#cL)McI$OiHu*-Sp6(2}tE7UT|Gkq7eZUt3>ct~Xb78aRaRUJYa=S#84t zc~k6>4_PJni~q5fkUx^qZI$x*<$l%4uB*h{xB>+rISLeJ`=4@C-&dO)txc^%KkfZ0 zP)HLBCTq#M|J|<~g}2z%TRVpPK!DfL;=@hYV{Ix8_bF*+eWA={`@Cup*=678Dx$Y6cfU&>xYI=POV#DuMZ^8`(v^BqPXC65X@FJg8BK&8;$- zkFj?_*_hf9jfFIF?p0h>IoKv?)!7%72|D|s{-_iUAiK#IWDnW9f;tM0ZrEYeHB{0~ z+m+H{kjx^fMm2)rhLL?usFr+1OFx(UTaGlNMl^!#Cx>1+A~YJ&So-x{5xoh*kcAwe z+76PiS7Qv$9202U%vy&u5z#RFA5Evwrtgx&wCNEN_V+A;Gbb$vYN2&&<5}oEx(v{4 zGzVGHTr>~OM+?wGa*P}&C&)>1ikv2A$XRRq00li5#RQCmic+zg-9V*+)w9j1@bCc2z$JD;$(G^5R6LfeAYfqaTSd!2OK&XfOGWeBVriSHX4LLRQaL?F-Ch zL(LUg)wShAY0N6`S^P?pU0c&A1eW!+!+O-#4{fL`D>t|5K8(HrQ5(<^bQB#!$I%IN z5}iV)(HV3WokQo**W^d?6Zx53BEOKrU&&?i8@WQRlHbWSa(x53fG(nMDN91o5A@$p z=x1~Z{epg_bGtzv^6(Ez9A_Th7C!Fq@Gg&Nrp1j%(ZbX>-w7H7E%(8lRd7Fgi2g#4 z&|~z3ZU=vmTjUP8OYRH4{go$W=+S~Y&6}lS-RS7i!Vxf!q52a=GWByj_c$495dT(20?gPF4 z7Tjdo*~0u-ISxh#gXAF#yF)=wzE|bUPGX+c`Q^a&)Ix}6EVjhO`FoK62c-WDL zQ9O+1VGIvrd8p!{nul>bjOSq@4>deY=3xpCQ+cT6VFnL%Jj|l*yA30HV4IU z-ZFb*WYgOrwXNPkb`fhW%IMmryP@`CY-@2Tqiu^iyU4*N+MCu^G{P=wXA?ze+lt28 zMNY!_tzNGwwAe*1wpO=aYc5(^?tmM)2MKS>##m>d@u9iOD5^UuyQL?;@J2}*+@ zOJ{t2)>G__-E4YuGTV-3hF#=AN7K2@tgZH9FWVrC)7!RKXczfVWo_-W#9r)c8(_R9 zTdB*A*F+l)n(XNKxOk%gOH#Bxk^Vn1UZ+aXsM^BPY;WvOZ69lWm>Sw~su`)>$ot2^s-zM5&zGr?EI{Sf#$~K)DnO~X9uUEXv z&;xthmN%F`UN5-K{3)oS{+woHUX9zGdB{8x%KzeFQk!xk^PG9{ngSNF^bp^s3l_7Y z*9+RQPE-|3T~JyZRqm{Wbr;Irc$ofLxeqIx)miGDGTW4guyQu=b;ZGKs8BJ4huLi^ z8rcqPEOpOpM>dL$W~o0i@X*A=Tps4HV3n+jRkPGF74oo#hl_c*#KX=!?7~CpW=nWT3%l{KI}dxTW4j9>0|;Szvwei^n(Yt5c-YexLr^c@ zhlfM#TlG-e&V0>><^$Mj8hoJRB(GKr}g3Z?SI+H72kVd05KB0nO|raE*s$G{6k& z-+Fj2FxONz4(?N3Rb#dVS>^^w%+}UErn9qYXvofBXR@<+SkA)=9-3R&IjohP%fmrD z9L&Qi+S`YkH!bY@bl8j7#q=ldVd9G@ja=6kP)g+4BPIU-NKW zE9@5q*y#<8@I%|$e_9VD?9c44!rU&gzwnSkJ-(T}%>G701DeLLL*DN?dsC=!Lx7yv zAipI*&i@DG5;(mT=KJg;0pMPrOG@JDZdx)(lmkl~JWwf-_p7wj<32aUpcBoPrDH z;cOnxY34#X3W1fUNtm#{gKHZaTXRT#E2#K%U)s)pV_N=POE~^52F}QtxEwB*%j5F70*=;M!o#IJT*kxYJZ$1&GY?yMxPpf(dAMpF z*U8Q?uEg$0xE=z-R<|;Y^82MD`Sg_|DdfzQQQRONu6fBQuIm3|6j#SJ2sRwfQJ$~k z;reE-k)urifQOst@SA*ttvT@_5!_gAJS7M>j(dZnyxYLT4|(`e3uoboK#+|*{P-0? z;@+WrnEdZmJZ78zTyt&q$);1I@)k_&ch$z7cWK>elsi5Nf&JQWXC^mC7}YF+#hV2d zb5?=HpZph#U1zr<@gBF>j>IAx5}#2dIIDofCw3$(&D@7{$#X5-3T`F0id)UC;ns5N zxb@rz9G%iu9&Y2|b{_8FA@u;C^Kcgrck}QI9`51c-u2u^FX7nC_TfGimOQsr0OZS7 zK)$jq`IGh~f9hXL-mqB{Jq#9d2Lyx;@^D`(LP!2zgidqk1Y4Zp&hl_S4-YhR=Q-*z zsDi_`HE)t}-*G<*RlXO@b?6_9UZ7z3W$t$YzTdbj9JTuq9v$ccU^$*I1f+! z4d0&>lDq$gZ(vtgIZu&oT2%eXs5uwJ={EQt(7F#PeEpLHHErN~%sqK66yTm?XhQ}A z8#1T=u|~WsScDyEuz((tKbmkm9-e)bJHpP`l}fM+56?A$Ym@=>%qA@{SJU*ExuW%q z*1FIeUUC6@U@zJV?kDq`F!d~7|EIh6Au12jU<-R`Bo}9$w(#MIL_3 z!|!N1RL%|6KuFM53dOswtwf;a5vojb=|#iALuPw7up~noqaYY`xsUwV3*& z7F>siW9pIa@$fzmAGF{`JOVq>?e`%MpYSxx8fKlJJ=O9C9xu$BdZfRaFx>_O`@bIxM0>SU#$ut7d(~I6_{LZTjR6G?=6ZF2z!>3Jn zIuD<{u9s?^jptyib1$m6!FH*@!{^o@gVG?xHFzGLPxG69Csb`UT4?bCFrg<+t=fKX zHO+f?k8l2ES|*1M{~u=$jMg{p)27!B3&A$%Ag!AE)I&La<=k{fxgz{l|kd=j7HkvETg zcqHYKFC98Iy=VFs9BIMd;qUPeJo4jF5RXD%z;!@Fn~U{*_1mJd*J!pauVi zui)c6lJh8#Y83bzW_8U8u(CNLvTx&i|IvhV!w)d!!%QA2c$%S(E(d|32n3$SPpIjh z;%5Tv6*Q0ENy4_b?#x-{(5qxbdAZqKv3Wr5YGheUpKEOeU(%;q2f!h4gx>0&rO&gj zf;-?jbfj;wq|l$fzmmfs`UWcvX46+zW9elQjgH_{ID=mI%z<--dmXr#zOGtMUs7G5 zkER~ci-2eF1d2!gn&ciKK%%fg(xY&43}vUt=OsJ64rcbD?CjsQwxVoIU|D0JTAiX!vMq3tzwK(- z>RjY)*~RXoFOx2?zp~fa+w?usJ@yd|FQ3sDNnxCpMnrw+6Qff4yr`TrbCp~bH~q-f z@V&zw$99gPj%vp^$9Ts?#|+0j#}dZ@j%LRi$0?5U9p7_Y(52j^&Sjj-n=Td?-erQz+b*+Q=D5st zneVdD<$agME}LCWxLkH^=c=^0CcCD%rnwqiySnyqEqASU9p+l=I@xu->sHs@u6ta+ zbp6WpsOxdpldh*-&$>Pp2Z{CKA>zs6+2W<*7V%2)YVil+55*hBo5bHqfW%SKPU0l- zl=w=5B_Wb9NvtGI(n-=qQX=Ul=^^PQ=_BbUDV3B-8YLqoqa|Y{Z%8cTB}DR;WTIq} zWU^$j*Z`_1jD z+cmcvZg<`8yFGM!S%{|w>vwN|7SNHDj{oG64%iPP|&F-VziThjb z6Wy1%Z*<@7{-pHr$1BDw$t%lC?`8Djy{3B2_gdn$)@z5?KCfF|mV4fE?+EXX z-qGH%-f7;s-ud2z-krR=c$av0^B(A3;XTNEu=fz}YVR8FI`3KDi@jHSf9ie6`&;in zyl;6w^N=HgZOUFvzkdBwWBb_0gC7mOkE1fTGk*<`k zmadh4CfzFCF5M|TBt0TMCOsj&D7_;6U3y*mhxCE3ldr@#+&A7g%{Rw4&$qz0$alW) zGT)WH>wNe7-tm3z=jtc%bNBP~^Y)Ya`T5EGWV^84EFC%;>Mcl_@9J@tFx5B(W`Z~s95B>znR9{v^nL;M^3NBWQU zAL~EEe}Vr}|6TqE{LlKI^FQzZjsIo;tNz#g|M0)%e@Dj1oMf&tiOgN*DGQJV%VK1y zvNV}imLW6AYGk8iGh|kaY@KYg>@(R`*>>3h*-6=H*;(0n**CJEWxvQS%dW_NmtB|L z3vdeX4hRZ}2}lY^4oD5q24n=}1mp!21QZ2y4k!+&3z#0TG2l?Z`G5-n-v;~;@KeC0 zfCm9Dpj;sjk$03w%VXs#d7L~=Zjcwtd&w<**X8dAIZOv@0IV9@0TBxAC@1LAD5q$pAH0p%D{es z(*m~yUI~&0r5vWioQWSj^Rf>s#_Z5p3OBLG` zI~1QQzEJE{>{A?298w%roKT!roKt+GxTyF}@i5pUI4C$LxGwmu;Dy1T1|JIkDa1V_ zG$bv=5KBpIwCeAEh0T4 zGa@TuZbVZ=OT@~EH4zUYnMf{D6xlAaZ)8>E(8yttb&-1`Peh)IJQI1oLqvy!4oMx7 zJEV1((P2@CB^{P^Xzp;YBkag@c#~~eec0APaNJq=DjwhoOQL#~~ zsJN)asL4_Dq83EG7qvL*TGYd+M^R6ro<|o%_loWl-7k7T^rqu2XJNZdGnm?ob|79#S4w9#vjYUQ~Xo z{9buYd0lx!c~kjB`BeE#`9kHSa#gvhJXPK*sVYj9tV&U(sZ|If zDpgghYE|{B5vozDF{){*_f(5j%T&#(6{=OLZL0mM1FD0n!>X@UmTy!SRNtzus(x2p zQ{7NKQax3@P$M;~#%ifLOdY9?Qpc)Q>NvGgU83%;?xpUl?ynx8Zcr07uYOBCQ9WBd zSG_>}zIw5GsrqB}F7*NRVf8WfN%a}^cj_P2m(-WlSJl_l57m#v@y2hmGtm&s2s4;5>YldlRHT9ZC%~;J8&3w%U%|^{;&1afznw^^6n!TE@GzT?D zG{-fUHCHv)G&d}oo0>bCdzy!uN17*^=gA-$CCii3lLsbGOkR_GJo!N zK4oIcJ1J9A=AcZ5+sg~=h_tIdRN1At9U|Muq zW?FVy=d^xl1JcUV2BlS{Rj1Xay^&@~DlSV^xX9R>1FBV>E`sx^dafP z(reR)r#GgLOdp*-Fa4ABZ_*!U1Z3zl24}pJu`1)sjH4MRGEQfl&GuftgjA)tR-K^_gQbEpKF6GT+Lan7JYIM;*{P z>b!K3x+qcV_R--jjVe`%Lz^?60#gWPh9eefG8NTiJhR z-_L%W{Y($^NH5oC={xDW>U-#W>r3?m^%eR-`VsoK^i%ZH^fUEy^zZ2x>zC=9^(*wp z^e+s7hDbxAA=gk~=w=vb7-1M~c-t_^@Qz`MVZOn#$gtGVWLRZbV_0w4VAyFmWH@R# zZa8H)YdCNC#qgWqcf$?CO~Y-YlTm8)HwGAkj3LG_W1LZMG#T@Zg~rat5@UB`FJoWh zFk_vu!8p=5+BnWQ-pCs#8YdZ-8$U4aHeNJdH9j#poBT{6rf^fFNo`6nX-p}mB2y2G zskf=0snpb9vYM8gnoP~66{e3(pP06owwpdT?KYh8a_N>3I&w zan2FvxaWA~NOSyhB6H$%v^kkMSvmTgE;(Iuy65!D>6_C(r!r?qPIXRA&bXYna^B9F zobztZoSb<%3v(9bEXmoG^B`BA8GZ+G6FynT5G@($-6%R8BOI`3@Wk9iOBUGo+B z$(H=0{GR!B`BU;|<~;f=dOL3$7MiD|9c^6%H#L zSvb1z?ZRn=GYjVw&MRD8xU8_baAo1@!Ve3#6z=X)cD&1}E*HC8?Q*NjgJPywT-ya!Qt|EkfB)t~%yaI&=bn4c_nvd^xzAtU!NuF) zN2Snhb}xmk6=)6eK>;WPZ9rSl4txN5fD+IXl!8HEFsKAWz)(;NMuRb692gHKgDGGJ z_z27fpMm*c0r(uO1gpSmum-FJUx1BZ8~6%*19pOM!2xg(d=HL*qu@9=1%3v9fIHwW zxCicoKfwd=5c~xmfxp2s8+Z-gKo95%MNk5RU?^08r!X8wz(^PkW1tQuz*J~}na~8y zFc0R#wy-BGg}q=I>AI7!})L#Y=Fz*YPcS5fSciVxC8El z-@;w+7kCDqh3DXTcmZC7m*8dC?pJsP-hg-DUHBM2fluKZ3Q&-uC{N0Z@}~HdFXc~3 zs31y8g;PpOMX9MMDway4(y0t8lggr6Q~6W@RYSM}I&7&4li>O9w9krg?Ky9aXP+wDLsdLnM>H>9W*xJU*~)BZb}_q|eavCz2y>h{!JJ{vG8dUEjO_;V2Xlvc$o$1TXZ~hhFmG6f zb!UB95$nhLvr;ygm9ycjhK*ul*#tI~HLw|M5!-<+W;?Q-*v@Phwkz9>?aqF{_Fzlc zo@^=Giyh2XvP0ORY!zG0*08ngXm%nyiJi<&VW+aw*g5Pc>}Tvkb{)H(-Oz&lg5Ah& zVz;ti+P|eU(J8F3zoBPB&E}a*9?UWV?FwK3X&@bBfJ~4DvatXQu`d>3KkSdiD}V}^ zfdyDW4#)*6%*O#Z4X0x>wvgW%yV9-3QsU#&(qb*@S2?`6rea`4O+|V2N#F&%0Uxvn zoq%>JC;}ZoG3bZ`u>=QU=~B=cbOBvKHyn(kuolM=MslCP7BQVFs|HjLDJ?6vSMpT$ zD4r_5iBT`m*TJX^^akai4-UbhScc_GK|fFd`h$Kr42NSS`PSGE@cIg?KsE4RvIHwm zfMH-b7y(Awv)mPE1Q=y6ch?HW0vqtJ2X$Cg4=`2}jSO}Vaz8=jod70+N%k3DAt(|@ z*ne{`vTaCvGJD=iH!mNNe}E+91kb&NXNMuBHT}9&4DMY%BCld_O@oI=*Nzpv$~)8! z9$Y@q2b_T3e9s=`BZia?uCAyYTvSt9Q=VWv@Mir?cJ&~_K;YZpAuRC`yc<*8vO{@w zO|=h2hWdHF?Yl09^c4#{>pciHu!*{?zx{BF5Kr&WCc>JY_D3!HdU0S9_yjBhjbJ(0 zVejYB-p#S_sbHFYk4F?U9ehZZ{=nm-a0hiJm{sEATUb<3TT|L=V7aBDtcJ*0S~aqf zG&yuc6d&HC_>T!&Z3D1_IXD`}G=RCF3yvjL)0Frq?Qi%}`(OOrw0DMU8nzHLIPA3u zECx$JJ&wmZoPZOT68V;bWuOr!;bfdbzMb}RUkBE^>_rTr5NrZl9Ol`K_4QyYPHkqM z?ZiA^zcr7+zTT@nvvHkNvfs{l_s}S^hhziCza|lOf$toW?*@Bt2F`2%d%-@Og^gs& zO+FoLhrr<_INODfAHa{D3X6_qMldH!GaI(ig3UhXB1ScF_ z71=|blm0X7Y49u1t^&V+GvF*Z2hM{F;3BvLE@Las!MQjOx5TY*Yplt~1*^ana1~qw z*TD^N6Z{5lf!}c2KUR49`Z+*rzc)%NXMUR5-N z?6r;+<-<>c$KVM8?^Ey$Jh%6kt8;yz55R3)qQ3wyyA>9-bfC2@!EFP01x7U|_<++8 zKnN+ohX~S;fh=@`Jm?Ot+86rv_bA03aBtif7vZ*cp-6A^0*9ADZ^(x}PymIv7w3Kv zIt`}V1rmQEbOsTc{0btT17^W&Xzc3n3`I3n6@&ZYKDfNZM{lo7h*8CiD68yUKBTI$ zziZhRXzga{*deE4V7bF2jW7r15>^ZSWsY47Tf$cMeg4s?HK9H7k8fQ7j@83LT!Bq* zp$*%?&Oo~ywueQq11yFeVJF-l55NQQAUqgXE(hVTE9^!-AAoS|g@@pwIL z3>*vV$ck+cLk^1JcsK!0gp=T8@_Qftu{74a7_y-KUg$xe!?Dj!%{Q!%U@13Vs&EAc7mW2X{& zM7wPLHT_rtY;IGJ3e!8BUzpoUg`|l@yQ~4p=pObCu>|Qc-3LtYxrY_Dv03=UYRzwKjj1X zlz8y_E^SI>ux%(m999NMa&-OH%Xc!^Whl!)@{NK{?i($YYQ zh!>~CWPHHC$DggQdbMb)F?wCn<(RzoV?M%5?3wyN8x>5+i11Vh6^fVPhO5-9vu);J!8eN_@f-jLD{CW2!aUoSKsyooF%ZqK*2Tgy=Y{PH#0^ zO>yz5DK60>sK^o@5y_IQy(?-etMV$U$@j>XHRXdED2)RaqL#M*(&)4ar6o3UZshDH z{l!s9B)U@Zl#WWE67d?m7O%tWmr}`83Y1f+cmv)-47U?&I_H=7amI))?JN5BbN=d@ zUplg~wx)o1q!J%LN8;v+BkASCosq>@Ro(B;R7a{4)fsQaU*c`}E4&@=z+dBU>>EPE zXp%id*?Y1!+RU4u|P%H@IE5teysR_uzMGnx;vh#JKBAG18x6k zN|9aN(;3p6q?$}k{}xGe4At8pWz?ssRj7Q|F!VNj)gCw>hV#0{N2KrQY-%@<0>NK8vGNH@feZu zAM7czE1V)WWh~Se6iK&i|Dm<5Ae`EUPY?^A#EKFhJ{jJWcQ^o|$nNq!lW(ZQK)a6G zNqtN0qIOezsPCw~)IMrIb$~iZeNP?2KjYK*7kmbv#pm#Od;wp?m+)o$E55RhIzs(G z>3?*1E$SHgJ3*bKPEkKorwPEW;y*Zez~Q_&c*yZ}n1erX@H_|q;E3xW$lH7&tPhO- z%@H%EQomETsXwSYB;Z`bH}G%xcSp?l$7@p$oF4kRJxke5@Q(V#Nxf+w;+N;&y!Q(y z{Z@%jt{o=m?HdvV?*<{@Y`ksHO6(qv5OO1N6w!!5EWU&9;(LvVhul#Me4m3n4t_{B zNT@mrz4Pj>3=v|Aimn$M^|;il5=<`0tfSiBw39B2XmKpeUrpFYqh;8o%Kn;2??JEC=0) zXp?g08XV3ar6Bzh{L<;MkO8F;zx(!xP*h&iNGR!G6p3Zz!MO)frn9?!ku*+;%*gT{ zrco|x=~z=92dR40ii3zu^gt@Mp+eNQ9<|{h%|WJJjBB!VRMg<$-oq6S^ND+G=7vy5 zL{5>-HVo>DN{C;AQ&D&7chm!O9CXLmIOwqy^+ctp7b@c*!Ab-Ni#a&tJ#~7behz(d zuthVw3jYBE8suvAbQrcT8iGcXAdH5hDpZYXP%RpUhNBT^BpSs*Zw~S~=)*w)2ZbE; z<)DazejM~CInin~#ubhcc7QMs+gE=U31?6(I0L;Xe;`XgK`draZu5SzM@*79UKhDWHO~QU`N?E zrG(n+QgpTxXg5LAo_|N){}Q>;KGJpnzq|fBNH+=eJv!nH)95f>#zFPJmt!L-yemdG z4LOc30__@f0-Z#s(9h^J`URaqXVE!y9$nyIBnLGdjN+h{gV7v};b1HW<2V@4K^+GZ z)}Tvojd>LwN7o&G3jO9VXJV5%lW`*lO)f80g!z=qq?-#(ePIE5;;`pa4kk6(^KW8L z!o>L@V_cu6&`v{|Caa?<8gVd%gZc)Vp~*f?@jH)l^-2Q3`YCYVmg3>863>yU{)h7p@XOv9L(mRu_?pi(P4m3D=4A;QJTzy zq*1hrJgdpGEL~QBm%LRUt)b(|af6PcwRAKcL&wrYIc5%8IB4Zy4hM5Nn75qPfp9tz z^rMq$B6mv%%v)iC6|&VGCy80P5((@e{nMZ$06PM73gMB&JuaRCrf9}8tSx|G>uXTWPpaYy6&>9X7a=06XeT*^CMsKCR za_N1WL+_Pj`_daoGwJmIRQ$IzNiNpVyXf8Y9{M|aFTIc6PamKU(%;jEI5?DpRUEA5 zU=0UrIXH}i!#OyDgCjXOii4xq&_~{C|0j5yRyt2dG&vn{a7>f_>m2)cs%!sFYchdr z`_k7O`oGS>u}%6XsmA}Y1W73%nCX>lxvKb>|V$6(%u`)RvB)Iv4gBv-xiG!OtxP^mT zIY@%kR~+2V!LK>ElY_fBxSNA}NX-3?gZtjg1(?>ZU_aA-EGpJMG(!0<4`AB5nm)1* ziHx!9BUR0sid{_~JDYCCHgD?cYMMiuV(r~xnlmhMHO;m6jPB5kS#KBlQ>O?6G|fj; zxSHlW=Q*ZnKF?rR)93crT2esOHgBqSH7#HtF7DHrds*oXpdFJs-NrE1lER?+HEI)wJ5#)cZZ5=enBK+C$>n_`b`1nFTKT zdguJ##3*g9YXxQzv-o`gH!zLw6P7b8-Y2YP*1S(x&un;~u!-6HKH*Dd+xvta0_Gc% z)G`EF+v>^b#F4x8)JejbJq#%|H%m~N{mel}#{(SP(X68{^8<6#LI071-@HdZ$^7h~ zpW@)R&FDGIIp+NPj4v^l-zQvUuDwsV$=q_V`i+Boo3Zj`?lSis^m`oK-;6F|9x+cy zv}7K0@L)ajl!N3zYY)l`>C3z%o(~8xMb1zzwX=Yw2nz?To<$rawP|O<$g-qB>Fn85 zpk!OHBu8mvJy=iHi-TmjM>+UYBg>M5{QfqeRWdvGL^i`|c#< z?9oWbIxwm;ISe_Uo90SnlO1ybJ>;QHk2T9m^z`=(cDeGzMUiqI{hJj;^>~MAjOTy_R&Jna;|5J8g$FP_j zeA%&V9ZP~8fzT5UK5b+Y&SpO*`635jaHQU* zDFZSk1!O1KxkP94*iRky?p<0_+D1V0?0X9qGaYkED+X@vU$}y@EeGqsRNBdI+Dv^#su)Mf&HYX4F7+q%7_~rN_Vw*TZ6pDH<$~vYcEJb#z=)uIQ{=W!&`p6t<~vUll7S!s<-r!&MQ zxVG^&=Y<#Gfj|%jv>=|`3ulqnKsti%pa(sKT+xpw*YR`dh2$E3DY=MWL$9MZ&>P7W z{C)Z*xn1Xz`*mM(!!9Ow>_JR0$pYd?29U_ul1UslkQkmx;&*4JFS$P7&0J*_1oMuY zZ3lR!J5)iIzJzUHm$J*r(zdbN*{|81>@Ic>yO-V19%Rq4cio_y$SuMx&8^U_n_F+U zK5qTo`nwHut92XhHqvdh+gLZ7TZ7w|Zr{7zb9=#~c`T2|Yr*rh@%(sVULY@s7t9Og zCG%SG%6N6W>AVklGkCLjpYj@cYkAvw-|`OfuJZ2l9`YXXp7NgaUby?ahq|Y`XS!#5 z2s|VnW{*}L)gEU&u6R84cUhTb#ygGPw^cw0_=f!!A_nPE2#cP_^GOzVs zN4$RY`pJ8O_YCj(-iy8Ky&Jt(daw3g>%HFljQ2~D)v2)>4&z&G(* z^4s%E_*MKG{xJRs{wV$!ejOk4$MGld=kY(|FW@iaFXq?V_>KH!{1yCF{5AZe{1-l~ zkJiWH)759J&k~<4K6`wA_POPA+vkqYJ)frnB;W}|0)IiQz$9oVs1S@1d@7hP_*}3^ zutd-xSSmOzxGcCWxFfhH_*3vu@JR4Ph=krkk6mAxNCEPDOC_E%QB0Md;CcGj1P58U;kuUW1@)i3A`Ud$1``Ub`_%8Bo z^xf|Jo$rsnKlvUPd5Zi*a#4gRQIsQUBN`>LiB^j?i@p?nCE6i6C^{uNEjlAQC%PcI zB>Gi!S9D+WK=haBvFNGjx#)#oke}L5=a=o*!RFV;Z-n0{zsY_x{XX*h*l&*C0>6cR zi~Z{T8vVZVJK%TL@2cMozu)|R_xJYK_!s*3^sn=ui~kA#Q~sy@&-kD7zu zh;2Q^y~LH`q2g+Bt$3Vxf_Rd6ir6k*B5n{b6)zWW7Jn^1C_W@UBK|QTHb5Vc8DI|R z5->GjZory=bpaa!HU?}C*cz}cV0*yV0XqYB1)K^v9dIV#T)>5ZO98(ITn)G$a5Lam z!0kY{KviI?z=44u1%4TLF7UY|K$0ZMlo%yuo5U(9lys7Gk#v)MASscQO3EZdB(;*^ zl97@+36_kLOpw$|_DPOQPD{>6&Pgsyu1Ky)Zb%*lfglv*6C@4_3ZJqUU&6-a%hQmIm^lcq>hrD@V^sYz;)+H$1% z(r(gm(&^F$=?dw$(u2~&(jTNprRSyBr8lLwq_?Gaq>rUfrO%}=q_3oJf_cHh;Nak- zU{i4G;DX>b!R>;Jg1ZOz2<{o&E4X)XpWq3>^}+jsF9zQZei8gC_)Q25K_N_tTZnsz zM~E~eBt#Yx77`w!3W*5OglI!zY$4r4J`C9&ax2s~)ErtHx-@iS=$6ngL$`;19lA60 zc<8y%%b{07uZ7+SeHi*Y^hM~a&^Izx#*?*>dCI(HQduM^hFE2VvTm|ovfi>jvVO8k z*-%-vtX4K$#>uA17R%Pk*2&h(zL4#d?UsEf+b=sPJ0!a(yD9rac2{;^W_uueEobCz za(B6hTqqaG{pA62i9B2$EjP%Ga*Sxw7swaN7t8DA zjq+vk74lW`HS%@x4f2ih&GMu2WAYR7pXI;E&&n^zFUhaSugiav-A>|Lsqsn8-i^|K&E6QuCD3xB-Qq@{jplYLPrz%p}idCIdT~z&5166}nLsV6& zTGa^EXjPpGtLCegtG267sjjLXtKHOo>JW9PTCUcpQ`DJii@HEvsBWtsq8_Q9q@Jvv zqMoLnr=G7~s9vIOR4-F+Q}0&qRUc3vQXf&DQlC}dQa@HdQ9o5bk66=969M9hp>5V0^~aYRGJ(?~ipFj5*B8W|R;jEsnkij0Yj zk4%hAiOi2|6WK1ZD6%-Rb7a@Z?vXtrOC!r7%Ol4{)<^D-{7vJbiPscsYBYAu7n<#w zZ#26!do+hNCpAB7e$kxOoY!2`+}7OJJk&ha*#6eMiULtoRA`hbs!ddfsE$!xqIyJ? zMwLaCN7Y1)jjD^nQ8S|EM9qtuAGI)QY1E3SRZ(lB)<^vk^-QbLCTO#@McQI*5A6VL zofd0nXg}7@(azI;u3fCH*EVW5X?JN)YtL#gXfJE8YHw(7Y5&mP(>~BX(msv$juu4w z+M@lU#nF;zX>@3`JX#U0ijIiZMCV5LiJlg{Hu_lflNfo7HKupW_?RUzt76v1Y>3$y zvm<72%>I~zF^6J~#QYd@F6MH~)tDPGw`1de z6XGVvO^cfm_fecZZb@8Y+_JcpajWCj#eEUCDehq0&3F_a8lMqg65lVrHhxt6*mxX2 zF@8$?^!OR^^W#^e*NxCVzWx9R3 zTL~=^ViGzej7XT5@MXfugbN9m6Rsv)Pq>@#B;i@Y-w7`hUMGS?uS8*@Ut&OFaH1?x zk*G|}ODs>UN*tLuCefBSE^%t&hl#TiXD8b1iTe}pCGnF2l9WmMBtw!psclkOQh8EU z((t5FNn?_@qzOrrlcpwpnzSHkWzxE&4N04lwk7RI+L^Q~>1fjVq=!k5lb$8LNP3+N zlj&r)qpWPNg4az=6%xsSFa=OmXUk4s*j{C)DB6k$q+ zEu}OCrz}qSCS`xh_bEqGeoQ%?ayjKn%C(dmDZi!sp7JE+MapYE)U$eby{Fz=AEhtU zchmROm+8y({q=+NmHMH2te>Huqo1druV1KNs$ZdBtzWBOum2_0JynyMkeZ!Zlv)Bn!M&*+lTE#rfXo*C;izRuX0u`A=dOi`vhQ<166 zjL008IX-h@=H$$2nP)O@Wd4@AI(0WeKGrT_En=T&X{4$G-er%#zJEoV_Rc;V~Me+vD8>*9AX@5 ztTNUZIpa9vc;iIl$40wxj&ZKB(YVyO%(%k1#kkG5!?@G9%ecpQ%6P?i)p*T#!}!qn z*!ax&!uZPg#w0X_n!-#3QX}ak{ z(@fI>)90pzrp2bUrVXY|rmd!JrtPL5OlM6OOqWepP1j8~P0!7&+1>1E=9`6PkvYs9 zZB90)n$ye~=2qqca~pGebFsOTxu?0zTyE}bt}%}`*O@u<1oL$BO!I8>9P?cBHuHT; zfJJGEutZs6Eb$gwf+f?EYiVQYZ0Tm{Z5d!0Vi{|}mhqNJmZ_HMmbsS2mQ|Lumh+bD zmM4~1R@Ulk^|uCErPffZ+}hUK#oEhSZmqITv`(>3x6ZWAw$8E6v(C3Jv@Wr3wr;h4 zW!+)jY29tzYdv5+WIba2(R#*uKZlnSl%vbZ&1siYVapkpGc{*c&X$}VIs0>d%sH2H zE$3#=?>TpJ?&mzqd6Dxv7v|ErY;I_-A-6bpO76_udAXnEHsr3#U7NchcT?_`+&#J9 z=N`#DntMF=Oz!#IOSxBaujP5>8S?t%Rpt%NtIM01HzjX+-pssD@;=R5khdsrN#2UQ zFY><1-aVqgWc85q16rQz%#Mh&PMCI*J~Oa>OHnkXO*0v4nJa0`PlBg3pY5xV%QuQiw3qZOUY$~jP%-qzHM1_jnoV;SI3R@+x3M(KRB&@Hb09I0xZL1XF8=&Bv zUzDm~re~mMpk&9TprBw=l#*r@P?Wt5Z@Sn2DRmzV368|&p4rRy77T3YHG z80i}s=>k>g7FXt#Bv$C=6)QswftllyTAW;zSx}OhpQivaH!&%{w8U0P31kr*K-^i9 znTD__uNdkrpa=CqGWv#k2KsQbfm&@qqE`MznW;dVLFU^T+JIG}h(YbK(Fa+MP^yyNloLEKhiwB~FJ{|1y(ROu zra9SMZAn_Z^<-?y?Mc%ozSjO$G=IOx$NnEOC$(L>e!MEpP9I-p|shUV~1(&nj zJLT_ki?Xx&xBrzmCVsThLPXwawx-m#>bXj{+14vr{}lTEabE~aujZ873pjF)%&kx^ zJF<0$D5L4OtN-U2-E2N=*)e}%t@br`u_&&kC%$RC>DvCNy`ty))JL8>X8Knves`JE zm7ioc_tW*u2Tuf7o+}n(mC*H_A+%!lhTH?ZhaXrj{%urgC1?1Ub>+3sPpbMR|M|Wl z?_~Uv87EC9%r<=X`pyfrjQ=(qb|?0%sQIq1{#RU*SF8Ea=5`BEI`nk)b6Mw<&;$T2 CRwH2m literal 0 HcmV?d00001 diff --git a/VibeTunnel/Assets.xcassets/menubar.iconset/icon_32x32.png b/VibeTunnel/Assets.xcassets/menubar.iconset/icon_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..dc156c1f72f109d82d3c579b1bb3864c4f54eed0 GIT binary patch literal 1224 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}EvXTnX}-P; zT0k}j11qBt12aeo5Hc`IF|dN!3=Ce3(r|VVqXtwB69YqgCIbspO%#v@0S_Ps>W0$H z3m6e5E?|PIR#?D{V1u;9@8SOdq&N#aB8wRqxP?KOkzv*x37~0_nIRD+5xzcF$@#f@ zi7EL>sd^Q;1t47vHWgMtW^QUpqC!P(PF}H9g{=};g%ywu64qBz04piUwpEJo4N!2- zFG^J~(=*UBP_pAvP*AWbN=dT{a&d!d2l8x{GD=Dctn~HE%ggo3jrH=2()A53EiLs8 zjP#9+bb%^#i!1X=5-W7`ij^UTz|3(;Elw`VEGWs$&r<-Io0ybeT4JlD1hNPYAnq*5 zOhed|R}A$Q(1ZFQ8GS=N1AVyJK&>_)Q7iwV%v7MwAoJ}EZNMr~#Gv-r=z}araty?$ zU{Rn~?YM08;lXCdB^mdSoq>U=!qdeuB!ctpwEcdffda?G1&-XfaPdZzgz$lLT`lg0 zi`IS&^;px;(p0`+k8bNUyA~lXLymvnf4@EV-1d#_?&@#Hj_%PWShFDmENHmpc5M-}-oliqAgH`{xCp@4fX~aCNa~j8Pij&;E@0?a7`(YWF9c z5B(whI>KOMbm`fy$qCOa{`G8A>D2plxMkV&J7x1Um+VwgIMntxX^oe=Mzz)F=3U!o z?wxM2>(}gcDrMDowwai9)vS{a65nmHc@rt#RhLJOF_t!6LcFZ*Eg$=6S%c%DVQXNgvz%2Lq`_RaTWd4K#^I$6_Cp)_mq zlY6yUI}`;^I57MFi-)hLdbiy@e{9ha z%MJQ3>J=62Y|lnNdpRks?Umk%nX=bkuYB;ePW`V>0jq-ReL#e4Dg;@7$K@k3&DqRCc!iTi|2DWtgC>sQRS$Y025WGDj@C4&Gy_(b{qP zXS+4ua>FMNKb1c={H$EQ%GCW*X>e)VABS5vYRz@q@87EHGk-DDO3b>{MO92fmc9L= gpRDJdvhuJ8{Aa@ZE~PI_Z2)CwPgg&ebxsLQ0DC3BegFUf literal 0 HcmV?d00001 diff --git a/VibeTunnel/ContentView.swift b/VibeTunnel/ContentView.swift deleted file mode 100644 index 99baa229..00000000 --- a/VibeTunnel/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// VibeTunnel -// -// Created by Peter Steinberger on 15.06.25. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/VibeTunnel/Core/Models/TunnelSession.swift b/VibeTunnel/Core/Models/TunnelSession.swift new file mode 100644 index 00000000..35b3ed9e --- /dev/null +++ b/VibeTunnel/Core/Models/TunnelSession.swift @@ -0,0 +1,72 @@ +// +// TunnelSession.swift +// VibeTunnel +// +// Created by VibeTunnel on 15.06.25. +// + +import Foundation + +/// Represents a terminal session that can be controlled remotely +struct TunnelSession: Identifiable, Codable { + let id: UUID + let createdAt: Date + var lastActivity: Date + let processID: Int32? + var isActive: Bool + + init(id: UUID = UUID(), processID: Int32? = nil) { + self.id = id + self.createdAt = Date() + self.lastActivity = Date() + self.processID = processID + self.isActive = true + } + + mutating func updateActivity() { + self.lastActivity = Date() + } +} + +/// Request to create a new terminal session +struct CreateSessionRequest: Codable { + let workingDirectory: String? + let environment: [String: String]? + let shell: String? +} + +/// Response after creating a session +struct CreateSessionResponse: Codable { + let sessionId: String + let createdAt: Date +} + +/// Command execution request +struct CommandRequest: Codable { + let sessionId: String + let command: String + let args: [String]? + let environment: [String: String]? +} + +/// Command execution response +struct CommandResponse: Codable { + let sessionId: String + let output: String? + let error: String? + let exitCode: Int32? + let timestamp: Date +} + +/// Session information +struct SessionInfo: Codable { + let id: String + let createdAt: Date + let lastActivity: Date + let isActive: Bool +} + +/// List sessions response +struct ListSessionsResponse: Codable { + let sessions: [SessionInfo] +} \ No newline at end of file diff --git a/VibeTunnel/Core/Services/AuthenticationMiddleware.swift b/VibeTunnel/Core/Services/AuthenticationMiddleware.swift new file mode 100644 index 00000000..2eb5bc84 --- /dev/null +++ b/VibeTunnel/Core/Services/AuthenticationMiddleware.swift @@ -0,0 +1,107 @@ +// +// AuthenticationMiddleware.swift +// VibeTunnel +// +// Created by VibeTunnel on 15.06.25. +// + +import Foundation +import Hummingbird +import HummingbirdCore +import Logging +import CryptoKit + +/// Simple authentication middleware for the tunnel server +struct AuthenticationMiddleware: RouterMiddleware { + private let logger = Logger(label: "VibeTunnel.AuthMiddleware") + private let apiKeyHeader = "X-API-Key" + private let bearerPrefix = "Bearer " + + // In production, this should be stored securely and configurable + private let validApiKeys: Set + + init() { + // Generate a default API key for development + // In production, this should be configurable via settings + let defaultKey = Self.generateAPIKey() + self.validApiKeys = [defaultKey] + + logger.info("Authentication initialized. Default API key: \(defaultKey)") + } + + init(apiKeys: Set) { + self.validApiKeys = apiKeys + } + + func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { + // Skip authentication for health check and WebSocket upgrade + if request.uri.path == "/health" || request.headers[.upgrade] == "websocket" { + return try await next(request, context) + } + + // Check for API key in header + if let apiKey = request.headers[apiKeyHeader] { + if validApiKeys.contains(apiKey) { + return try await next(request, context) + } + } + + // Check for Bearer token + if let authorization = request.headers[.authorization], + authorization.hasPrefix(bearerPrefix) { + let token = String(authorization.dropFirst(bearerPrefix.count)) + if validApiKeys.contains(token) { + return try await next(request, context) + } + } + + // No valid authentication found + logger.warning("Unauthorized request to \(request.uri.path)") + throw HTTPError(.unauthorized, message: "Invalid or missing API key") + } + + /// Generate a secure API key + static func generateAPIKey() -> String { + let randomBytes = SymmetricKey(size: .bits256) + let data = randomBytes.withUnsafeBytes { Data($0) } + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +/// Extension to store and retrieve API keys from UserDefaults +extension AuthenticationMiddleware { + static let apiKeyStorageKey = "VibeTunnel.APIKeys" + + static func loadStoredAPIKeys() -> Set { + guard let data = UserDefaults.standard.data(forKey: apiKeyStorageKey), + let keys = try? JSONDecoder().decode(Set.self, from: data) else { + // Generate and store a default key if none exists + let defaultKey = generateAPIKey() + let keys = Set([defaultKey]) + saveAPIKeys(keys) + return keys + } + return keys + } + + static func saveAPIKeys(_ keys: Set) { + if let data = try? JSONEncoder().encode(keys) { + UserDefaults.standard.set(data, forKey: apiKeyStorageKey) + } + } + + static func addAPIKey(_ key: String) { + var keys = loadStoredAPIKeys() + keys.insert(key) + saveAPIKeys(keys) + } + + static func removeAPIKey(_ key: String) { + var keys = loadStoredAPIKeys() + keys.remove(key) + saveAPIKeys(keys) + } +} \ No newline at end of file diff --git a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift index 4fa611f0..85fd933f 100644 --- a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift +++ b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift @@ -28,7 +28,7 @@ import UserNotifications public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate { // MARK: Initialization - private static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates") + private nonisolated static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates") /// Initializes the updater manager and configures Sparkle override init() { @@ -86,23 +86,10 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse /// Configures the update channel and restarts if needed func setUpdateChannel(_ channel: UpdateChannel) { - guard let updater = updaterController?.updater else { - logger.error("Updater not available") - return - } + // Store the channel preference + UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel") - let oldFeedURL = updater.feedURL - let newFeedURL = channel.appcastURL - - guard oldFeedURL != newFeedURL else { - logger.info("Update channel unchanged") - return - } - - logger.info("Changing update channel from \(oldFeedURL?.absoluteString ?? "nil") to \(newFeedURL)") - - // Update the feed URL - updater.setFeedURL(newFeedURL) + logger.info("Update channel changed to: \(channel.rawValue)") // Force a new update check with the new feed checkForUpdates() @@ -112,37 +99,28 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse /// Initializes the Sparkle updater controller private func initializeUpdaterController() { - do { - updaterController = SPUStandardUpdaterController( - startingUpdater: true, - updaterDelegate: self, - userDriverDelegate: self - ) - - guard let updater = updaterController?.updater else { - logger.error("Failed to get updater from controller") - return - } - - // Configure updater settings - updater.automaticallyChecksForUpdates = true - updater.updateCheckInterval = 60 * 60 // 1 hour - updater.automaticallyDownloadsUpdates = true - - // Set the feed URL based on current channel - updater.setFeedURL(UpdateChannel.defaultChannel.appcastURL) - - logger.info(""" - Updater configured: - - Automatic checks: \(updater.automaticallyChecksForUpdates) - - Check interval: \(updater.updateCheckInterval)s - - Auto download: \(updater.automaticallyDownloadsUpdates) - - Feed URL: \(updater.feedURL?.absoluteString ?? "none") - """) - - } catch { - logger.error("Failed to initialize updater controller: \(error.localizedDescription)") + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: self, + userDriverDelegate: self + ) + + guard let updater = updaterController?.updater else { + logger.error("Failed to get updater from controller") + return } + + // Configure updater settings + updater.automaticallyChecksForUpdates = true + updater.updateCheckInterval = 60 * 60 // 1 hour + updater.automaticallyDownloadsUpdates = true + + logger.info(""" + Updater configured: + - Automatic checks: \(updater.automaticallyChecksForUpdates) + - Check interval: \(updater.updateCheckInterval)s + - Auto download: \(updater.automaticallyDownloadsUpdates) + """) } /// Sets up the notification center for gentle reminders @@ -182,8 +160,6 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse /// Checks for updates in the background without UI private func checkForUpdatesInBackground() { - guard let updater = updaterController?.updater else { return } - logger.info("Starting background update check") lastUpdateCheckDate = Date() @@ -192,6 +168,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse } /// Shows a gentle reminder notification for available updates + @MainActor private func showGentleUpdateReminder() { let content = UNMutableNotificationContent() content.title = "Update Available" @@ -222,27 +199,42 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse // Schedule reminders every 4 hours gentleReminderTimer = Timer.scheduledTimer(withTimeInterval: 4 * 60 * 60, repeats: true) { [weak self] _ in - self?.showGentleUpdateReminder() + Task { @MainActor in + self?.showGentleUpdateReminder() + } } // Show first reminder after 1 hour DispatchQueue.main.asyncAfter(deadline: .now() + 3600) { [weak self] in - self?.showGentleUpdateReminder() + Task { @MainActor in + self?.showGentleUpdateReminder() + } } } // MARK: - SPUUpdaterDelegate @objc public nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { - Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items") + Task { @MainActor in + Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items") + } } @objc public nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { - Self.staticLogger.info("No update found: \(error.localizedDescription)") + Task { @MainActor in + Self.staticLogger.info("No update found: \(error.localizedDescription)") + } } @objc public nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { - Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)") + Task { @MainActor in + Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)") + } + } + + // Provide the feed URL dynamically based on update channel + @objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? { + return UpdateChannel.current.appcastURL.absoluteString } // MARK: - SPUStandardUserDriverDelegate @@ -252,16 +244,18 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse forUpdate update: SUAppcastItem, state: SPUUserUpdateState ) { - Self.staticLogger.info(""" - Will show update: - - Version: \(update.displayVersionString ?? "unknown") - - Critical: \(update.isCriticalUpdate) - - Stage: \(state.stage.rawValue) - """) + Task { @MainActor in + Self.staticLogger.info(""" + Will show update: + - Version: \(update.displayVersionString) + - Critical: \(update.isCriticalUpdate) + - Stage: \(state.stage.rawValue) + """) + } } @objc public func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { - logger.info("User gave attention to update: \(update.displayVersionString ?? "unknown")") + logger.info("User gave attention to update: \(update.displayVersionString)") updateInProgress = true // Cancel gentle reminders since user is aware @@ -281,11 +275,11 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest ) { - logger.info("Will download update: \(item.displayVersionString ?? "unknown")") + logger.info("Will download update: \(item.displayVersionString)") } @objc public func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { - logger.info("Update downloaded: \(item.displayVersionString ?? "unknown")") + logger.info("Update downloaded: \(item.displayVersionString)") // For background downloads, schedule gentle reminders if !updateInProgress { @@ -297,7 +291,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse _ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem ) { - logger.info("Will install update: \(item.displayVersionString ?? "unknown")") + logger.info("Will install update: \(item.displayVersionString)") } // MARK: - UNUserNotificationCenterDelegate @@ -327,7 +321,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse ) { if keyPath == "updateChannel" { logger.info("Update channel changed via UserDefaults") - setUpdateChannel(UpdateChannel.defaultChannel) + setUpdateChannel(UpdateChannel.current) } } @@ -335,6 +329,6 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse deinit { UserDefaults.standard.removeObserver(self, forKeyPath: "updateChannel") - gentleReminderTimer?.invalidate() + // Timer is cleaned up automatically when the object is deallocated } } \ No newline at end of file diff --git a/VibeTunnel/Core/Services/TerminalManager.swift b/VibeTunnel/Core/Services/TerminalManager.swift new file mode 100644 index 00000000..d32c5e7e --- /dev/null +++ b/VibeTunnel/Core/Services/TerminalManager.swift @@ -0,0 +1,163 @@ +// +// TerminalManager.swift +// VibeTunnel +// +// Created by VibeTunnel on 15.06.25. +// + +import Foundation +import Logging +import Combine + +/// Manages terminal sessions and command execution +actor TerminalManager { + private var sessions: [UUID: TunnelSession] = [:] + private var processes: [UUID: Process] = [:] + private var pipes: [UUID: (stdin: Pipe, stdout: Pipe, stderr: Pipe)] = [:] + private let logger = Logger(label: "VibeTunnel.TerminalManager") + + /// Create a new terminal session + func createSession(request: CreateSessionRequest) throws -> TunnelSession { + let session = TunnelSession() + sessions[session.id] = session + + // Set up process and pipes + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + // Configure the process + process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh") + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + if let workingDirectory = request.workingDirectory { + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) + } + + if let environment = request.environment { + process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new } + } + + // Start the process + do { + try process.run() + processes[session.id] = process + pipes[session.id] = (stdinPipe, stdoutPipe, stderrPipe) + + logger.info("Created session \(session.id) with process \(process.processIdentifier)") + } catch { + sessions.removeValue(forKey: session.id) + throw error + } + + return session + } + + /// Execute a command in a session + func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) { + guard var session = sessions[sessionId], + let process = processes[sessionId], + let (stdin, stdout, stderr) = pipes[sessionId], + process.isRunning else { + throw TunnelError.sessionNotFound + } + + // Update session activity + session.updateActivity() + sessions[sessionId] = session + + // Send command to stdin + let commandData = (command + "\n").data(using: .utf8)! + stdin.fileHandleForWriting.write(commandData) + + // Read output with timeout + let outputData = try await withTimeout(seconds: 5) { + stdout.fileHandleForReading.availableData + } + + let errorData = try await withTimeout(seconds: 0.1) { + stderr.fileHandleForReading.availableData + } + + let output = String(data: outputData, encoding: .utf8) ?? "" + let error = String(data: errorData, encoding: .utf8) ?? "" + + return (output, error) + } + + /// Get all active sessions + func listSessions() -> [TunnelSession] { + return Array(sessions.values) + } + + /// Get a specific session + func getSession(id: UUID) -> TunnelSession? { + return sessions[id] + } + + /// Close a session + func closeSession(id: UUID) { + if let process = processes[id] { + process.terminate() + processes.removeValue(forKey: id) + } + pipes.removeValue(forKey: id) + sessions.removeValue(forKey: id) + + logger.info("Closed session \(id)") + } + + /// Clean up inactive sessions + func cleanupInactiveSessions(olderThan minutes: Int = 30) { + let cutoffDate = Date().addingTimeInterval(-Double(minutes * 60)) + + for (id, session) in sessions { + if session.lastActivity < cutoffDate { + closeSession(id: id) + logger.info("Cleaned up inactive session \(id)") + } + } + } + + // Helper function for timeout + private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TunnelError.timeout + } + + let result = try await group.next()! + group.cancelAll() + return result + } + } +} + +/// Errors that can occur in tunnel operations +enum TunnelError: LocalizedError { + case sessionNotFound + case commandExecutionFailed(String) + case timeout + case invalidRequest + + var errorDescription: String? { + switch self { + case .sessionNotFound: + return "Session not found" + case .commandExecutionFailed(let message): + return "Command execution failed: \(message)" + case .timeout: + return "Operation timed out" + case .invalidRequest: + return "Invalid request" + } + } +} \ No newline at end of file diff --git a/VibeTunnel/Core/Services/TunnelServer.swift b/VibeTunnel/Core/Services/TunnelServer.swift index 0f3bb049..9346b899 100644 --- a/VibeTunnel/Core/Services/TunnelServer.swift +++ b/VibeTunnel/Core/Services/TunnelServer.swift @@ -6,19 +6,27 @@ // import Foundation -import Hummingbird import AppKit +import Combine import Logging import os +import Hummingbird +import HummingbirdCore +import HummingbirdWebSocket +import NIOCore +import NIOHTTP1 +/// Main tunnel server implementation using Hummingbird @MainActor final class TunnelServer: ObservableObject { - private var app: HBApplication? private let port: Int private let logger = Logger(label: "VibeTunnel.TunnelServer") + private var app: HummingbirdApplication? + private let terminalManager = TerminalManager() @Published var isRunning = false @Published var lastError: Error? + @Published var connectedClients = 0 init(port: Int = 8080) { self.port = port @@ -27,210 +35,201 @@ final class TunnelServer: ObservableObject { func start() async throws { logger.info("Starting tunnel server on port \(port)") - let router = HBRouter() - - // Serve a simple HTML page at the root - router.get("/") { request, context in - let html = """ - - - - - - VibeTunnel - - - -
-

VibeTunnel

-

Server Running

-

Connect to AI providers with a unified interface.

- -
-

API Endpoints

-
    -
  • GET / - This page
  • -
  • GET /health - Health check
  • -
  • GET /info - Server information
  • -
  • POST /tunnel/command - Execute commands
  • -
  • WS /tunnel/stream - WebSocket stream
  • -
-
- -
-

Quick Start

-

Test the health endpoint:

- curl http://localhost:\(self.port)/health -
- -

- Version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "0.1") - · GitHub - · Documentation -

-
- - - """ + do { + // Build the Hummingbird application + let app = try await buildApplication() + self.app = app - return HBResponse( - status: .ok, - headers: [.contentType: "text/html; charset=utf-8"], - body: .init(byteBuffer: ByteBuffer(string: html)) - ) - } - - // Health check endpoint - router.get("/health") { request, context in - return [ - "status": "ok", - "timestamp": Date().timeIntervalSince1970, - "uptime": ProcessInfo.processInfo.systemUptime - ] - } - - // Server info endpoint - router.get("/info") { request, context in - return [ - "name": "VibeTunnel", - "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "0.1", - "build": Bundle.main.infoDictionary?["CFBundleVersion"] ?? "100", - "port": self.port, - "platform": "macOS" - ] - } - - // Command endpoint - router.post("/tunnel/command") { request, context in - struct CommandRequest: Decodable { - let command: String - let args: [String]? + // Start the server + try await app.run() + + await MainActor.run { + self.isRunning = true } - - struct CommandResponse: Encodable { - let success: Bool - let message: String - let timestamp: Date - } - - do { - let commandRequest = try await request.decode(as: CommandRequest.self, context: context) - - self.logger.info("Received command: \(commandRequest.command)") - - return CommandResponse( - success: true, - message: "Command '\(commandRequest.command)' received", - timestamp: Date() - ) - } catch { - return CommandResponse( - success: false, - message: "Invalid request: \(error.localizedDescription)", - timestamp: Date() - ) + } catch { + await MainActor.run { + self.lastError = error + self.isRunning = false } + throw error } - - // WebSocket endpoint for real-time communication - router.ws("/tunnel/stream") { request, ws, context in - self.logger.info("WebSocket connection established") - - // Send welcome message - try await ws.send(text: "Welcome to VibeTunnel WebSocket stream") - - ws.onText { ws, text in - self.logger.info("WebSocket received: \(text)") - // Echo back with timestamp - let response = "[\(Date().ISO8601Format())] Echo: \(text)" - try await ws.send(text: response) - } - - ws.onClose { ws, closeCode in - self.logger.info("WebSocket connection closed with code: \(closeCode)") - } - } - - // Configure and create the application - var configuration = HBApplication.Configuration() - configuration.address = .hostname("127.0.0.1", port: self.port) - configuration.serverName = "VibeTunnel/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "0.1")" - - let app = HBApplication( - configuration: configuration, - router: router - ) - - self.app = app - - // Update state - await MainActor.run { - self.isRunning = true - } - - logger.info("VibeTunnel server started on http://localhost:\(self.port)") - - // Run the server - try await app.run() } func stop() async { logger.info("Stopping tunnel server") - await app?.stop() - app = nil + if let app = app { + await app.stop() + self.app = nil + } await MainActor.run { - isRunning = false + self.isRunning = false } } + + private func buildApplication() async throws -> HummingbirdApplication { + // Create router + let router = Router() + + // Add middleware + router.add(middleware: LogRequestsMiddleware(logLevel: .info)) + router.add(middleware: CORSMiddleware()) + router.add(middleware: AuthenticationMiddleware(apiKeys: AuthenticationMiddleware.loadStoredAPIKeys())) + + // Configure routes + configureRoutes(router) + + // Add WebSocket routes + router.addWebSocketRoutes(terminalManager: terminalManager) + + // Create application configuration + var configuration = ApplicationConfiguration( + address: .hostname("127.0.0.1", port: port), + serverName: "VibeTunnel" + ) + + // Enable WebSocket upgrade + configuration.enableWebSocketUpgrade = true + + // Create and configure the application + let app = Application( + router: router, + configuration: configuration, + logger: logger + ) + + // Add cleanup task + app.addLifecycleTask(CleanupTask(terminalManager: terminalManager)) + + return app + } + + private func configureRoutes(_ router: Router) { + // Health check endpoint + router.get("/health") { request, context -> HTTPResponse.Status in + return .ok + } + + // Server info endpoint + router.get("/info") { request, context -> [String: Any] in + return [ + "name": "VibeTunnel", + "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0", + "uptime": ProcessInfo.processInfo.systemUptime, + "sessions": await self.terminalManager.listSessions().count + ] + } + + // Session management endpoints + router.group("sessions") { sessions in + // List all sessions + sessions.get("/") { request, context -> ListSessionsResponse in + let sessions = await self.terminalManager.listSessions() + let sessionInfos = sessions.map { session in + SessionInfo( + id: session.id.uuidString, + createdAt: session.createdAt, + lastActivity: session.lastActivity, + isActive: session.isActive + ) + } + return ListSessionsResponse(sessions: sessionInfos) + } + + // Create new session + sessions.post("/") { request, context -> CreateSessionResponse in + let createRequest = try await request.decode(as: CreateSessionRequest.self, context: context) + let session = try await self.terminalManager.createSession(request: createRequest) + + return CreateSessionResponse( + sessionId: session.id.uuidString, + createdAt: session.createdAt + ) + } + + // Get session info + sessions.get(":sessionId") { request, context -> SessionInfo in + guard let sessionIdString = request.parameters.get("sessionId"), + let sessionId = UUID(uuidString: sessionIdString), + let session = await self.terminalManager.getSession(id: sessionId) else { + throw HTTPError(.notFound) + } + + return SessionInfo( + id: session.id.uuidString, + createdAt: session.createdAt, + lastActivity: session.lastActivity, + isActive: session.isActive + ) + } + + // Close session + sessions.delete(":sessionId") { request, context -> HTTPResponse.Status in + guard let sessionIdString = request.parameters.get("sessionId"), + let sessionId = UUID(uuidString: sessionIdString) else { + throw HTTPError(.badRequest) + } + + await self.terminalManager.closeSession(id: sessionId) + return .noContent + } + } + + // Command execution endpoint + router.post("/execute") { request, context -> CommandResponse in + let commandRequest = try await request.decode(as: CommandRequest.self, context: context) + + guard let sessionId = UUID(uuidString: commandRequest.sessionId) else { + throw HTTPError(.badRequest, message: "Invalid session ID") + } + + do { + let (output, error) = try await self.terminalManager.executeCommand( + sessionId: sessionId, + command: commandRequest.command + ) + + return CommandResponse( + sessionId: commandRequest.sessionId, + output: output.isEmpty ? nil : output, + error: error.isEmpty ? nil : error, + exitCode: nil, + timestamp: Date() + ) + } catch { + throw HTTPError(.internalServerError, message: error.localizedDescription) + } + } + } + + // Lifecycle task for periodic cleanup + struct CleanupTask: LifecycleTask { + let terminalManager: TerminalManager + + func run() async throws { + // Run cleanup every 5 minutes + while !Task.isCancelled { + await terminalManager.cleanupInactiveSessions(olderThan: 30) + try await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) // 5 minutes + } + } + } +} + +// MARK: - Middleware + +/// CORS middleware for browser-based clients +struct CORSMiddleware: RouterMiddleware { + func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { + var response = try await next(request, context) + + response.headers[.accessControlAllowOrigin] = "*" + response.headers[.accessControlAllowMethods] = "GET, POST, PUT, DELETE, OPTIONS" + response.headers[.accessControlAllowHeaders] = "Content-Type, Authorization" + + return response + } } // MARK: - Integration with AppDelegate @@ -239,16 +238,15 @@ extension AppDelegate { func startTunnelServer() { Task { do { - let portString = UserDefaults.standard.string(forKey: "serverPort") ?? "8080" - let port = Int(portString) ?? 8080 - let tunnelServer = TunnelServer(port: port) + let port = UserDefaults.standard.integer(forKey: "serverPort") + let tunnelServer = TunnelServer(port: port > 0 ? port : 8080) // Store reference if needed // self.tunnelServer = tunnelServer try await tunnelServer.start() } catch { - os_log(.error, "Failed to start tunnel server: %{public}@", error.localizedDescription) + print("Failed to start tunnel server: \(error)") // Show error alert await MainActor.run { diff --git a/VibeTunnel/Core/Services/WebSocketHandler.swift b/VibeTunnel/Core/Services/WebSocketHandler.swift new file mode 100644 index 00000000..8974128a --- /dev/null +++ b/VibeTunnel/Core/Services/WebSocketHandler.swift @@ -0,0 +1,196 @@ +// +// WebSocketHandler.swift +// VibeTunnel +// +// Created by VibeTunnel on 15.06.25. +// + +import Foundation +import Hummingbird +import HummingbirdCore +import NIOCore +import NIOWebSocket +import Logging + +/// WebSocket message types for terminal communication +enum WSMessageType: String, Codable { + case connect = "connect" + case command = "command" + case output = "output" + case error = "error" + case ping = "ping" + case pong = "pong" + case close = "close" +} + +/// WebSocket message structure +struct WSMessage: Codable { + let type: WSMessageType + let sessionId: String? + let data: String? + let timestamp: Date + + init(type: WSMessageType, sessionId: String? = nil, data: String? = nil) { + self.type = type + self.sessionId = sessionId + self.data = data + self.timestamp = Date() + } +} + +/// Handles WebSocket connections for real-time terminal communication +final class WebSocketHandler { + private let terminalManager: TerminalManager + private let logger = Logger(label: "VibeTunnel.WebSocketHandler") + private var activeConnections: [UUID: WebSocketHandler.Connection] = [:] + + init(terminalManager: TerminalManager) { + self.terminalManager = terminalManager + } + + /// Handle incoming WebSocket connection + func handle(ws: HBWebSocket, context: some RequestContext) async { + let connectionId = UUID() + let connection = Connection(id: connectionId, websocket: ws) + + await MainActor.run { + activeConnections[connectionId] = connection + } + + logger.info("WebSocket connection established: \(connectionId)") + + // Set up message handlers + ws.onText { [weak self] ws, text in + await self?.handleTextMessage(text, connection: connection) + } + + ws.onBinary { [weak self] ws, buffer in + // Handle binary data if needed + self?.logger.debug("Received binary data: \(buffer.readableBytes) bytes") + } + + ws.onClose { [weak self] closeCode in + await self?.handleClose(connection: connection) + } + + // Send initial connection acknowledgment + await sendMessage(WSMessage(type: .connect, data: "Connected to VibeTunnel"), to: connection) + + // Keep connection alive with periodic pings + Task { + while !Task.isCancelled && !connection.isClosed { + await sendMessage(WSMessage(type: .ping), to: connection) + try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds + } + } + } + + private func handleTextMessage(_ text: String, connection: Connection) async { + guard let data = text.data(using: .utf8), + let message = try? JSONDecoder().decode(WSMessage.self, from: data) else { + logger.error("Failed to decode WebSocket message: \(text)") + await sendError("Invalid message format", to: connection) + return + } + + switch message.type { + case .connect: + // Handle session connection + if let sessionId = message.sessionId, + let uuid = UUID(uuidString: sessionId) { + connection.sessionId = uuid + await sendMessage(WSMessage(type: .output, sessionId: sessionId, data: "Session connected"), to: connection) + } + + case .command: + // Execute command in terminal session + guard let sessionId = connection.sessionId, + let command = message.data else { + await sendError("Session ID and command required", to: connection) + return + } + + do { + let (output, error) = try await terminalManager.executeCommand(sessionId: sessionId, command: command) + + if !output.isEmpty { + await sendMessage(WSMessage(type: .output, sessionId: sessionId.uuidString, data: output), to: connection) + } + + if !error.isEmpty { + await sendMessage(WSMessage(type: .error, sessionId: sessionId.uuidString, data: error), to: connection) + } + } catch { + await sendError(error.localizedDescription, to: connection) + } + + case .ping: + // Respond to ping with pong + await sendMessage(WSMessage(type: .pong), to: connection) + + case .close: + // Close the session + if let sessionId = connection.sessionId { + await terminalManager.closeSession(id: sessionId) + } + try? await connection.websocket.close() + + default: + logger.warning("Unhandled message type: \(message.type)") + } + } + + private func handleClose(connection: Connection) async { + logger.info("WebSocket connection closed: \(connection.id)") + + await MainActor.run { + activeConnections.removeValue(forKey: connection.id) + } + + // Clean up associated session if any + if let sessionId = connection.sessionId { + await terminalManager.closeSession(id: sessionId) + } + + connection.isClosed = true + } + + private func sendMessage(_ message: WSMessage, to connection: Connection) async { + do { + let data = try JSONEncoder().encode(message) + let text = String(data: data, encoding: .utf8) ?? "{}" + try await connection.websocket.send(text: text) + } catch { + logger.error("Failed to send WebSocket message: \(error)") + } + } + + private func sendError(_ error: String, to connection: Connection) async { + await sendMessage(WSMessage(type: .error, data: error), to: connection) + } + + /// WebSocket connection wrapper + class Connection { + let id: UUID + let websocket: HBWebSocket + var sessionId: UUID? + var isClosed = false + + init(id: UUID, websocket: HBWebSocket) { + self.id = id + self.websocket = websocket + } + } +} + +/// Extension to add WebSocket routes to the router +extension Router { + func addWebSocketRoutes(terminalManager: TerminalManager) { + let wsHandler = WebSocketHandler(terminalManager: terminalManager) + + // WebSocket endpoint for terminal streaming + ws("/ws/terminal") { request, ws, context in + await wsHandler.handle(ws: ws, context: context) + } + } +} \ No newline at end of file diff --git a/VibeTunnel/Info.plist b/VibeTunnel/Info.plist index 5224bbe9..bb782998 100644 --- a/VibeTunnel/Info.plist +++ b/VibeTunnel/Info.plist @@ -30,6 +30,8 @@ NSApplication NSSupportsAutomaticTermination + LSUIElement + NSSupportsSuddenTermination SUFeedURL diff --git a/VibeTunnel/VibeTunnelApp.swift b/VibeTunnel/VibeTunnelApp.swift index 9a2a132a..ec59a1c5 100644 --- a/VibeTunnel/VibeTunnelApp.swift +++ b/VibeTunnel/VibeTunnelApp.swift @@ -14,27 +14,16 @@ struct VibeTunnelApp: App { var appDelegate var body: some Scene { - WindowGroup { - ContentView() + #if os(macOS) + Settings { + SettingsView() } .commands { - CommandGroup(replacing: .appInfo) { + CommandGroup(after: .appInfo) { Button("About VibeTunnel") { AboutWindowController.shared.showWindow() } } - - CommandGroup(replacing: .appSettings) { - Button("Settings…") { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - } - .keyboardShortcut(",", modifiers: .command) - } - } - - #if os(macOS) - Settings { - SettingsView() } #endif } @@ -45,6 +34,7 @@ struct VibeTunnelApp: App { @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private(set) var sparkleUpdaterManager: SparkleUpdaterManager? + private var statusItem: NSStatusItem? /// Distributed notification name used to ask an existing instance to show the Settings window. private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings") @@ -64,10 +54,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Initialize Sparkle updater manager sparkleUpdaterManager = SparkleUpdaterManager() - // Configure activation policy based on settings + // Configure activation policy based on settings (default to menu bar only) let showInDock = UserDefaults.standard.bool(forKey: "showInDock") NSApp.setActivationPolicy(showInDock ? .regular : .accessory) + // Setup status item (menu bar icon) + setupStatusItem() + + // Show settings on first launch or when no window is open + if !showInDock { + // For menu bar apps, we need to ensure the settings window is accessible + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if NSApp.windows.isEmpty || NSApp.windows.allSatisfy({ !$0.isVisible }) { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } + } + } + // Listen for update check requests NotificationCenter.default.addObserver( self, @@ -139,4 +142,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate { name: Notification.Name("checkForUpdates"), object: nil) } + + // MARK: - Status Item + + private func setupStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem?.button { + button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: "VibeTunnel") + button.action = #selector(statusItemClicked) + button.target = self + } + + // Create menu + let menu = NSMenu() + + menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ",")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: "")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + + statusItem?.menu = menu + } + + @objc private func statusItemClicked() { + // Left click shows menu + } + + @objc private func showSettings() { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + NSApp.activate(ignoringOtherApps: true) + } + + @objc private func showAbout() { + AboutWindowController.shared.showWindow() + NSApp.activate(ignoringOtherApps: true) + } }