From 2ab7e279eb4bb9176bb93bfe56c6a732968491e1 Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 6 Aug 2026 16:18:32 +0900 Subject: [PATCH] feat: enable hybrid local and remote module execution --- .../__pycache__/bridge.cpython-310.pyc | Bin 29195 -> 31130 bytes resources/slam-ros-bridge/bridge.py | 78 +- src/main/index.ts | 702 +++++++++- src/preload/index.ts | 65 +- src/renderer/public/slam-viewer/index.html | 289 +++- src/renderer/src/App.tsx | 1178 ++++++++++++++--- src/renderer/src/components/ChatWidgets.tsx | 9 +- src/renderer/src/components/Sidebar.tsx | 26 +- src/renderer/src/components/TitleBar.tsx | 168 ++- src/renderer/src/env.d.ts | 66 +- src/renderer/src/features/AiGuideView.tsx | 22 +- .../curriculum/CurriculumHomeScreen.tsx | 1 - .../features/curriculum/LearningInfoCard.tsx | 20 +- src/renderer/src/features/curriculum/types.ts | 7 +- src/renderer/src/features/help/contextHelp.ts | 41 +- .../platforms/PlatformSelectionPanel.tsx | 3 +- src/renderer/src/features/platforms/types.ts | 15 + .../workspace/ModuleDeleteConfirm.tsx | 7 +- .../src/features/workspace/ModuleListRow.tsx | 146 +- .../src/features/workspace/ModuleListView.tsx | 266 +++- .../workspace/ModuleParameterDialog.tsx | 17 +- .../workspace/WorkspaceEditorPane.tsx | 16 +- .../features/workspace/WorkspaceModals.tsx | 10 +- .../src/features/workspace/WorkspaceNav.tsx | 4 +- .../features/workspace/WorkspaceQaPanel.tsx | 2 +- .../workspace/WorkspaceSettingsView.tsx | 130 +- .../workspace/WorkspaceSourceControlView.tsx | 94 +- .../features/workspace/WorkspaceTopBar.tsx | 4 +- .../workspace/WorkspaceVisualizationPanel.tsx | 184 +-- .../workspace/WorldMapSelectorModal.tsx | 76 +- .../features/workspace/moduleParameters.ts | 4 +- .../src/features/workspace/sourceSettings.ts | 33 +- src/renderer/src/features/workspace/types.ts | 8 + .../workspace/useWorkspaceModuleControl.ts | 114 +- .../workspace/useWorkspaceModuleList.ts | 85 +- .../workspace/useWorkspaceVisualization.ts | 120 +- .../features/workspace/workspaceModules.ts | 1 + 37 files changed, 3267 insertions(+), 744 deletions(-) diff --git a/resources/slam-ros-bridge/__pycache__/bridge.cpython-310.pyc b/resources/slam-ros-bridge/__pycache__/bridge.cpython-310.pyc index bad063b757d7030920ac61f4fb88969cbed7f1db..21bc8ed36525771d914279fcec18716368a6ed94 100644 GIT binary patch delta 12758 zcmZ{K3vgS$1O)>0-b=kg z22P~dNn=-OH;=}49IB6`PVBS^`=@o2f10M#f2K3>-*%>d(%k%>PCMx&nf#sR<+^qp z`~S|l5CB0kIrm`q?Afztch8=4_T2mNAL`HjQjg~A>cSfSoL|d{?|=1oq8*0c|J4l# z^n$JlO*CY;PHszVo7|q*KDi^YV{&I=C*vElyC!!hc5`__cF*MA#NNrCL=V@Svb~di zi9Rmt+5SYo<)7GxXTMnZtgdU;0nuz7v;q@{<_?QRqUDZpQ@?2>j)+$4=%^vuMEfm& z;vrG!u#N(CY}79nizPrk%r!$S6`iOZ7t5?;VmTh735XS9C9o&Nsv^cOR*N;j42ZSB z#H~TG4o|DqXdP?SMoqC^Y(VSDqng+#x^8Kr%UXNWmpFy`Cee+0cYzV7f!Hjz0I|h7 z4T3YMZx!26-^T4jsBafLP~Ty7qkUNDsk0blxK* z-<2EGzXUGzt^rD}C9s;nDuB38uE3A$vokr@m(JU9Lv92j;U72c;4ujS!nl?XJpT&e!p zTx@Qjo&gzGubFK-Xq?L{cFKs_sH-I%~<~sLW3$pO{WLmds%_deq|3QhmGX z4ZTvJIaCkU1>8&iYAjI#%aU|cuT221Z_IMyez}7XI|-quUEQvhBb?4pPUUSYxkU_u z$c_R;^{Bq2)>xu9qkzTfRZjSC-d7&Ei$>TDfYJJ>%#!b=QV)TunTImDE0%OH;G<}@ zV*p_ztcUeBqfL+K294Mcq#Jf*Drb-8<)rOKocXWUKB@<30pu>Vur9u+mx@Ff56gX2 z-cR6wI$O6p#$r^At6r>Y)koD|)NR#|tG|yfR-tI0-lZOj_8cETFSlkin@>4zeKMKK z`hC&2lMSXn(-){W{^ee>Cq)aMj|(*({C z7$U$+^$1E(Uq?Q0sA06;!efU4!cbcitAyc()i>&o?x^Bio^x5&^XT_+V&DfbaiZjT zUW2o-zK5&2CqN;IOr9gaB0h*E?c8lV%rdMs>GDzadTg-~Gt@h=Rm&b0rWFt&JYhVw zR!~Gl-Dp@uMg6Usgei`z0}a~?F&Gv29GLzF;j^gLD%wzM6cHSTu>|Q`6}>Q*s1~F4)?{52qwJ4YAe7Sg{%mg z;`sG5NyrLg*nn6k*0Zf!T0&>lEFX_+~4BA-9OFz$_Eh zi&DUCbnZ$mcwfW!CmW0fH;VjwTy}LDjpJt!E;>&25yQLc0EwETn~w3sBL&!^J|HXLt1=L{i<=L z{%O^^;I9fZS~BMNCu-&bv_E%@xrRHMXuqNX-{>q59Zu84LZ^9Rky!k!Z&n9p2{0`s zj8UW^Oe@p)Drh{6Ul6x8hV2YH7%m1>15Kd<*E$(4%Mk5yu@rCx!<7tIF4i(eH@)r!0cX0DgXV={BJ0#AY z8EtN_(=($@1o8CF7;p|#$TNKfHR1Comg_mhm|}DaR;3JVpXwz$~irwop;WFBzBVs zhCncUN1HnSJ2 zMs=q7bVvREfrBTKr%nzXP7a?ub?o4c@TSycI-4Z7IRE?2D|AD4sc$cOMSnz1wJhJ= z47=wVdD{(Ixhol&&&fT63Fie|xs#vDq}{NcPUVW_`Vueomn})%Rc)<1TUyXVwgMz- z4;&sEP98pT8UJ6Oy{R_ zj@#t5Gv;b4n@u{1t=t8kJV_hgZQ4$zQ|WQbZMzrAe5ExiEqh%3qAeNkpqZJ;d@e~d zl5He*E#c}2R}xb$R-^3~^(E?W+q>1(3{_B#_8KYFLcVaWXoBC{UCMbNZ#EEDcKob=3 zVO%c|q8^Bv3W7$|0~PfJBFMx|!tBvXRc_vUgF_FQs=RQk+ulC3F zM9{X<`JAvTmYKqGL#|TWm#!)-D(|Bk7`IZwl5P$3Z*q#2NVbDEVR8sOX^+VTsJa0s zpG3tCA$m=wv-$L8*O$v*l^i4a=}5XJV217v6~k_(zO{Lov2tGYhWo(?_Ma{Y3BQc!9?53x@wn+}(*v|5;hkre)(uWtww)TY{#9aW!RyJRojC0u{n z%4A*NWG0sgUzHihN;=jxM{xB3)0h+Y@EdpO5aPOka&jNQ9XIuV>`sw=ai}&%lL49r#U_KwG(u4RshlW|^gTjsG zp=@e$FfXjGdn}^TH?-X^>IaA?#q!<|&rs<>Lp+a$o1Ul_H|)Hh&kS|^NQuw0@5ko_ zGDZa7tz-Q}ViNj>2b*q|%qG77!jJm3A`NjH!Gk z$A@!T+*b1&2k#ef798bM1b8ifn@SIw=;zSzoG0L+u5I`8`5<+Cy2OXB91rC4A{ypA zKHuo-D#-gbm+orv6D3B!^L~ux(C`Ijgu;!6dp4IGY8q{&zdDq{c<6N5e~1=Go73)S zA1komxZSO5AElDfg4D-lO}0CTUnY=uI;R;G{$q$}YG!r7z$}8xiQp{JnX`Vt&}TpklRUCO`ufYpo!K`zqGjP{Vpmy@=nM5;isyDz-Pygo~y2nOZ7| z&5as(qlK6o)pO|R@RN>Qdcr#NI)SJA6ZAqkwk{KIImlD_A(BW%oYl_|eB#~ta*aUI}@&u*<;&q-q z3vmoi`2F#KW%L)aM=yP?RY_TB3urz<$qzAM3fPw9Y{13 z7c^N$H5Hc@p-LGmEs~Ud(;HM`J&9B*z{`jrHJcWYay?Nzv3CQ$077N&-_$$aY^ybV zEyw~zkqa_)KOW;Z(EugxqPpL}^KRHrIkmtWW?cZS;df16>^G+G#=-~cocvFWR$<%J zSFC{g@TT5F@?Da#D#Z9ca8P;bQ|%)fljA8HYBV*Sg-QwX+uu|E?loaaQIh;`fcc%> zPZ>+QsJe{6P6B^RU=0CI?slrvy^GabTUH%mpQXD#cpRvGiQ*++!bG$;mdPcrl}gv; z_lN_pEiY>jDxJ2n*`F%0byp!pjdXy1Lg()%f~q7CCoo?Ma)?88` zZ`bt9dT-(*qCu`9w-XxK?DDF2;y(~;cgh`#NCQsq7+(6(}0#V|^ zBF>|V+@_UwgSk`=;d@%P=t$2Zdix?#vJ$;XCEf}i^xnYDjV7(pQOgTGFojw}{ba|x zg;tU`D34LshY6HdfrZ&fxCqISLMh(hg+2QSi{Wuu;OsKA6sI#`%3{*Dy!vi5J)X%5 zWav|qRuaMMz5EBf+0yi68;#OV;7O`}6yWakzC;Kbk416y$`miDsJsKP#8C6Qb_NEh zKB#`S>(#00w>c4?PA}FVG zxlC?M3Y_0A{X6Q(-UF>H4v)`g((FONJR(wk3H5{CgIiY6vaKXAO46^EGeLu{=&m z%D$dU%U9H2_qDDencNz>9N8>d!aqHo5w36Cy5_0UCyC9cl-|F7B~P)uJgj!>695M&%Q z`A6#U{o4mVLws4FJyiM>krofwMxrV)NYo*>Go5vcu|}2oma9N)&Hu~(0lk5T*o^wH zXQr2{-h=xJJP@(x(ctvxQk}hL^{8>$J3-tjNI+PE`Kz9(2{j>QGANr=Rpv`qrf`y_sWIun7Tt;l7L3D`4 zw84#`BrV?e!bREfdP#PP zl2J(K!riM=$2S*Ba(nT$;f%WBvNlQ|P4sOrNz@i&S2Eu5&E+JRMOE;VJ3=N-8p1a93u?w&P;7Nbs+z0@Bcv*5k2@(mo&_o*WTXO>j$ta+E)?~+sN zp8xv5QoRXj=GwrU0o@<)za4nj2qThyef~#-bw(fcE?%hIKz?-8##?hwZxS`587nKQ zY@{3Rq1zB^&Ox8ra$43=jLKBhQGNCF!dS^}$#0|WkWYR0bbq0&ylg&FXn9-jdt2{u z_0ekE!nUV)f@l-n0AEz>_###gTt_$uV>{Lq$61L0%WAtmByTEqjRQ;3tWF(VAMk}TMZTNNlg!@>m) zo(^8AGw zX<+dAx6fbE3t8&Hr?-5VrwCVdnBOBD-ewe&tGB575du#U_z;1o2~fD}r3Id$(z679 zn*e7CZliQ}(mlj%Z2}m##<@O)nO9HRy9NtuWP0p_YRiSo6>oCrkj3>QaeX-6B~KFn zQv^;E$PlO}@OuPk7rhS{zDXrN0XDNgqf!Tf&l0F5@En1~1fD1G0)bx;_#8k%MydEB zfzK0|BS2=(jnIp zyMU6yWA^}olXAnwpyyX@$0p zjVl@>%?(n&pv=dA+#kZgc->Qr+iTJpr+kbZ56LEQPnanok~!qRI1o zB8`rcdgGWeWyKN@XV=VTMlE|Pm3x_v4GC#HX(D}M8c`FM8^bgry8k_y1&bT?Lnm7H z4|JY6In;RoFDS;WxB=D7IJmKA)tThUa2VY=x_nH1IeBsilBGTo%xL(m&3qw^bVvIpp9#vZ|AwAm~}4mR;I%p^Wi+vn2)a^oBe#me)^?t9mx{-Yn~ke~0GM2)Y

z zO~6rqml`-+W=E>{l8$#~sq6Sis@fg0?fF-dE>dDiA1L7~qqj75VdTeq{)uGzKLBnp zKQe)PyzSk{4L67cpH1)au44$e7jU`fEtQ&0uMP0Q{lDtV>HW)ztYTt3r^wa3#4SCW zx}KkQeyXCPwc$e`yAj$%?~OLX3&?A*{7veFNTn(!37TP8`W3{ItYzl2C?TLM&zp?} z87t`$I0t|Z$c4l55U5gUBL6QHwOaL`skPRI6H`R<1TpnhFr_10(X+fa5jJ|>>v$V) zmNj{WNIy*AaipaO9=lg~mV6jji?YCMWd=yrK?3})t4c_62|+OTKCQ(5$4CQyt*K+9 zv*)S?p!>^D6pc@d^njuGlFrMbKWIf@3t-Oc{c7=8Ti_P)xu`luf?kV=dt3%oX= z3i;VUmSc#GGzpGz%LDnm_2*0FgErwE^l78{(NQ7&Ht_kV-xqziQ8%A7-s8)~jmAxM z-Bi2AlMC*h@-UK`Th*7wPd!94zGvUqWS1ASv~O(7SJ8;!;)rxGv8LQ%jNT`xM>0dP zdk2y?)vK8cLo8hC2OBV@p_Z$WwNDGPb5j8XMMwGU5 z*!#Fum)lk0@}lFcI^dLDdNYKPB*E0v!I4 zPxD@1ijAmm=(D!yc9$ z?Lx4;OE#6F^d$1hvYD_UkL-!OEf>+i6!Y_UM*QhcqR53m324`Fz&QD3Be{kYmEA>o zst)Qw;ZP#tjBY98m#FTk&cY9=l>?N^RN`&8MDyp6YXjlfUT>z-b^^Tw`U#MwkjDv} zATUheJb_0El%s$ls*-nRSD%PdfH9d7B5PeOq>vrTQ`0now}_IgeS$s=aU@%8wwSD0 zY;(vWc!%*L)P;3`Ux`0MRf;2BAF`GedQdvsuGur07x+w@Zk(Epo17YaVs{Ols)+jN h6YD#)Laf8^#q>zCABM)y|ABW*Gpw6+>a{2O{y(Q=+pYir delta 10803 zcmZ`<32P=Exn{7#!C0X9olB~t6Y|HX0d6Rc};bYux_4|@q?#=Ii$u<$w z5StJ}2skff0TQfCWo96d3?(X4nWQo>OllUA44InB@+&o&8At-eNh*OU5<}|GoR}yYIg3-uvF~N8i*QcvTB$N=rj7`l}g8jtzbJYPimQ-SceQ4lS2jqpwM= z)z_xh>FYF?aEbEd`qTz}L#kWv=6pqRV``JWiPKtgv%cBzj`h&9MN~ehX)dEzR2f?h z-`KWm+r@mbV9I@2yX@9?h=s<^VYjFjHCMd)E>UajBud|~S1b~BMCs?8TjUmtddlt= z4Mv}6qz6nsu|zZxb&ps&L-C4bVmVRviWNjzY3vit^ei+gjJ_Jza6m*v3zhEQ?Gmk` z?TSmZ8O@hH`T*ruiFV4jbN&G3J47etJBs&#@X+lZ3eE_N{KB$IX$X_x3@$|NW3svPBj9&z@LLB)I~x46u|S;*m?}WqZs-+V;dVX2dO9iJ*H& zW65FJ3_b?Pv@6e#$1Fpp6Pa{$SY}euOR)=`>IMINtwa6T|6;BI?2QBwkL{J2WYR9m zCam}+)C2KM_L7+xN$c)xLiff(AKC#@R=O?SExlB(Vp}cQo%`7RVMr2Zm)VWkeI=U?d<95Lq4s90D8$90Bk!4N{7hwIy%a_E9SRC_N?|LC6)*0yIL@ zKIn1v>v=oZ7f8>KLcRkhi1~g9@e%}Rx5*Pc0{4{l?klK%iZ~?LvrciKT3rYN%uxm6TA)a(a82 zBWQ%E9iLc{6DygmcDDM5F$YAmh)`3DsNp(lxJ9&5ZZY{KpIBx1sL6KGL6myYF9Jjh z(i5Vm*zl7|q2Z9|6kS(}^agTFeyR_rKU!!gNpPeVz1c5v=E8iEP9C6 zWGo%l#1>;2j}o-^vhOLx6)m?aM6FdhR%Oi}TObxa=($THN*z%a&QjbnJfKuF zk7o{#L-7hot5H{~mgN#iHj2fBOBkCNmohG6T+Xuw~T zAU&HVJr3Vk2$XXka(&e-9UP!-o+F9W$c+^fv1c;hBy@9VdGT9_R!UFr4e`{6JS|^$ zEAfQs*)~VD?V^z=JLWXIT`U1*(o3r5(D$9zE+UkOx+xD0Q6E$KX{$K--PWE-m$jET z=3PelJ}Tco<+?T?mJ-eXm`g09cQL(}(>p-#74#0$dnLU?^lqkiDZL}~E`QAR#qbn` zlfp#{twp`N;K=mY!X`~!t$s>7pn7VWD(91lu-zHc_8aMmgv_L6w>n=_r@mBkO4HTb zH5;lI!pu|?=*2twjtxfp`uh(Z9h3`Hf9(c!f9+}YcJ00DbBin$tvfxx7UBX?^6F+h zmXLpl3vmROcK~$E0HqfWlBEjmXCq_B|zQ^BE*m|e!oyx8&>4l^dAE)?+#`yyR4^z zgYhE58-IREc<9+lkNFJ(%XQgxR}oExMpNOY$3yG8Xv`Zen)LG9H>uIvPyc~vaMC@S z4q3&Mp4?^aF5je2Xf>z=rV?WE3HO|Qi1MB}`BKW0nVjVhiy}_X6TTkTY}!xhzzjXu zL#u17>{|I`(d>+e4x-1UH-&0y{gUOm3Z4f$NbyHBp3KD0*`9Rfg6*?1(Udu2`$mnJ zFr-~%TCr4CvIF!Y1hE5z35%k(IYZe&BV-q6WWq>WF)o`83U!KICSo!XOUDiQIq>cjjtD|h7k+8On7WT|#ay%p)& z{avDB7!o6+mLAGVVa`{6jh01Q*|9$jYJNx%uR4PuDP{W;W^_1_ zG;F_lKAEtLHM&2ZNvDmtC6B9Jt^JL>86e2^j>v?V(E>vL9q@hiRBLcXEmb# zrD|;N{Ggl()cFel%efb+TgiEd3Wgmy#T^?yDCYty=4R!5?8D?dOa)0t&b=M?ZF`=w zbZBAQ!m2q_fV*}d zQn%{bRVuj4QR(-cYd2(E|@5pQmbcY~vxhlhU`;`7=~-KeffdHp1Pe_wOt}mW-ti zW`xlW)9=eB$@agZe!l)lpY0vWWRis&Ji0r|aCufDZwu^QR5yy|%>h-kJak+!IJ z4u`t7skZth)Zq4c<@?F}#f@ZgO8v*C?i_rNq<*?ycLM~nZvfU4Jn3hvICrn3$k}C- zvNbDGWBce-Gh&$Xt0?=ht!)>@q>NqSQ-@t-jc1dFyiO-PmmM_Hnfl%ip@x+Mee5`61%2{{}@7b-rhT zc6IvWJr!CG9poYX)gwPz@mg_vd9VCEks?*{HI%TC*<0Xa zjh>D#o!R!yoJmQ$ zIv2Rp#Rk4uHlT{47#mBcKyM52YgvR92>76z9P$lUI($QzANHBwvLt zXu(Gt+^0={cKac>)}Vg5bJq$Eq}m{;U`&@14Y3^k1-7YyU1#Tgoszm=j;GU!^oSgy z4cV?euin_TqlQ&t9ngI{&_niPNW^-}RA1lDTo8j80$|rgR!DST`at+RM9#YqOIT65 zE{K|jq?-zP9_6^E(7n4ev7`(k|5d>3Jj%a9_OAhd1K=s+;d>g{xqYDllpP>G13>?LD;p~pbz<=q7p2>Gk>eEgkGs>Fa9JJKI_Lo2@OYSxON|HlGSImN_Fu5OI2b}U*Hc0SVS z7!eBB;Mc+YH~o_D-Ks|XeQ-u7u;pZ6W#d+v}=^ZnQKjuWNsUeu-Kaf)8H zFEc!B(nY?rTJ)mfgpm}5hAPy9x#IQmN4I+LlwAEhx-ui@IZD$fii-*JNlo`V@@A}X z1Z>|R_>)_8-L`ekjW%z5gfH)Cz3*r}wl+Lx5py9MERQqSm`&TqJIRbJ$vM;a6X>u6@Y8BSy@bx8LkT)v3p&|50qGBwUvF%E8$5l0{35$~ z8AVE+$z83E>P_7o1a8E{yo*X}kjigF?gP&feM5Clr?Qh$Z@Q-Q#}} zLU?=dM&W(Kdtx)yq76pJIGcie_Sa-#9s@hCC|+B<)Y{P!E6=(WtdS5*ieuvzcvSD{ zpK0A{(H)0(@H7_j5a~q;`gk;q7lpKRuR6 zx_Y*%Lh=@aU6M^`X{n}u7rnh$52C|t`KJ$^ozQY);Nabx-^?k{Zr;p#8P|!2`6PWA`HvIa7={&4 znfGFmbty|jQ8)^0hoiz68XsAsl0)a_TnRo8Ia+^28;rCTeh5M|D-lWXWug;oA4m3Y z0bal=H2o@4BV$@jas9UZWB$4Gb)Ly2Vz1*({xoJ4?;7qnWfLL zhy-naGBYxQgF=s*G*&tn#5|F#-(g}IQGaI~$w5=DEFXYC-yk=@mzC0D$Eyd-(Q!*; zE~M!`=MYtts7>yeIp=gAlI=h;F>ILGSUTpR?pG1XeRse^2OoDhK!+c9z+Iww-LE_y>k9k!6arn?m;eXsmfn(s{pyPjEr=q;Wmsq6~as<^~ zBdZ5F<8`{x1r2%PG4?cWUbvz{HE}7Mv_|iNl4ZnK=5~`}&VNYry(4c|q0u`xVN|6L zEwkACPM94^9x2QC6bX70j9wBG&}2+~XEa`X2Gyg0v#Kp|pf4{Di|a{^E^uR)D1Iz- zaNN&Fpj*kTNPG#UPnnIH`cC4tP5%O&{*}P?XNJb;#7>{wDHn0uemcULxKlQ$333o5 z#U)1p2|%~{@3CedZ?uU^?B zdsRJCh^HRbFib7TdjnC02$RV(%0k@OrxTbzMTCEyWou_Z^ z1$SYPjurXYVoR|fN(}(wH@C^!!q9&UGZr8YEP^sD1Nu7jbn32?1r4+j%j*h4Fj}{0 ziVx>JKH9B@hW0si>qU>cJ6-F`Le6P*Ej^jz!3oaMn~hsO1ZaiwgMfSk9%ll|AmvuG zu#^gZfPPiX^vg{@Uh#Uu?^S4lyWQ`TZ=-cl(SgIIdMp#Iyt%7`bT3(}O0$ReprI%64_(|r?NW$$vUF}TGn z+9ioJeUPDh37kCs7fRgNfId>98fu(ZKR^F}?D7U>BQ-!hL~K1$&VSEtjg;8FSR7Xe zjwVyYLqCtfjmMZt+ma0loQWP{qI2H?7i0VRHYAa8PW4zwY|CjA;p$W`C8uNI3O8zo z=Ux@G}5Md=DTs1vskaO>}C<)wYR;G^Jji*pizkYEoDb3Z}N>j1}EBQ=1Y9Y(4Jz_z;ssWpJj0Q^wM`3cKjr1k-h0!{!< z0`iXj2(oa%?9KHsVu(~ih@^2L7o)48+fft)@c9~+ACo{QUbf(DL3u5)>45=o_U3t{ zc-F^}x(GNxZP^~Ww45{Zvs;~RDpMITo-}%i)n$I0K4$;;;zq4n&A-%ITXxj#Dbq?8 Xc None: + # AMCL transforms the initial pose at the message timestamp. On a real + # platform the bridge clock can be a few milliseconds ahead of the + # latest odom/TF sample, which makes AMCL reject the pose as a future + # extrapolation. Anchor it to the latest received odometry instead. + with self._lock: + odom_stamp = self._odom.header.stamp if self._odom is not None else None message = PoseWithCovarianceStamped() - message.header.stamp = self.get_clock().now().to_msg() + if odom_stamp: + # The odom callback can lead the TF buffer by a few milliseconds. + # Keep a small margin so AMCL can always transform map -> odom. + message.header.stamp = (Time.from_msg(odom_stamp) - Duration(seconds=0.1)).to_msg() + else: + message.header.stamp = self.get_clock().now().to_msg() message.header.frame_id = frame_id message.pose.pose.position.x = x message.pose.pose.position.y = y @@ -355,6 +378,12 @@ class SlamNode(Node): self._scan_count += 1 self._last_scan_wall_time = time.time() + def _on_amcl_pose(self, msg: PoseWithCovarianceStamped): + with self._lock: + self._amcl_pose = msg + self._amcl_pose_count += 1 + self._last_amcl_pose_wall_time = time.time() + def _on_point_cloud(self, msg: PointCloud2): with self._lock: self._point_cloud = msg @@ -393,6 +422,51 @@ class SlamNode(Node): def _on_plan(self, msg: NavPath): with self._lock: self._plan = msg + self._plan_count += 1 + self._last_plan_wall_time = time.time() + + def topic_status(self) -> dict: + now = time.time() + with self._lock: + if now - self._topic_status_last_refresh < 1.0: + return dict(self._topic_status_cache) + scan_last = self._last_scan_wall_time + odom_last = self._last_odom_wall_time + amcl_pose_last = self._last_amcl_pose_wall_time + point_cloud_last = self._last_point_cloud_wall_time + fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time + map_count = self._map_count + plan_count = self._plan_count + + def received_recently(last_received: Optional[float]) -> bool: + return last_received is not None and now - last_received <= 3.0 + + try: + plan_available = plan_count > 0 or self.count_publishers(self.plan_topic) > 0 + cmd_vel_available = self.count_subscribers(self.cmd_vel_topic) > 0 + navigation_available = self._navigate_to_pose_client.server_is_ready() + except Exception: + plan_available = plan_count > 0 + cmd_vel_available = False + navigation_available = False + + status = { + "scan": {"name": self.scan_topic, "available": received_recently(scan_last)}, + "odom": {"name": self.odom_topic, "available": received_recently(odom_last)}, + "map": {"name": self.map_topic, "available": map_count > 0}, + "amcl_pose": {"name": self.amcl_pose_topic, "available": received_recently(amcl_pose_last)}, + "point_cloud": { + "name": self.point_cloud_topic, + "available": received_recently(point_cloud_last) or received_recently(fallback_point_cloud_last), + }, + "plan": {"name": self.plan_topic, "available": plan_available}, + "cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available}, + "navigate_to_pose": {"name": self.navigate_to_pose_action, "available": navigation_available}, + } + with self._lock: + self._topic_status_cache = status + self._topic_status_last_refresh = now + return dict(status) @staticmethod def _has_populated_map(grid: Optional[OccupancyGrid]) -> bool: @@ -738,6 +812,7 @@ class SlamNode(Node): "map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"), }, "navigation": navigation, + "topic_status": self.topic_status(), "plan": { "frame_id": plan.header.frame_id, "poses": [ @@ -904,6 +979,7 @@ def health(): "has_point_cloud": bool(snap.get("point_cloud")), "has_map": bool(snap.get("map")), "topics": snap.get("topics", {}), + "topic_status": snap.get("topic_status", {}), } diff --git a/src/main/index.ts b/src/main/index.ts index d495c7e..1f17123 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -47,6 +47,21 @@ function writeRendererDiagnostic(kind: string, message: string): void { // Diagnostics must never interrupt the LMS window itself. } } + +async function readAppDiagnostics(): Promise<{ path: string; content: string }> { + const logPath = join(app.getPath('logs'), 'renderer.log') + + try { + const content = await readFile(logPath, 'utf8') + const recentContent = content.split(/\r?\n/).filter(Boolean).slice(-500).join('\n') + return { path: logPath, content: recentContent } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { path: logPath, content: '' } + } + throw error + } +} const skippedCacheDirs = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']) const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|urdf|xacro|yaml|yml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i const imageFilePattern = /\.(png|jpe?g|webp|gif|svg)$/i @@ -135,6 +150,7 @@ type CachedModuleInfo = { dockerImage?: string | null packageName?: string | null launchFile?: string | null + runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null parameters: string[] cpu: number | null memory: number | null @@ -153,6 +169,7 @@ type CourseModuleInput = { dockerImage?: string | null packageName?: string | null launchFile?: string | null + runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null } type CustomModuleScaffoldInput = { @@ -170,6 +187,7 @@ type ModulePrebuiltArtifact = { fileName?: string | null checksumSha256?: string | null extractPath?: string | null + architecture?: string | null required?: boolean | null } @@ -194,6 +212,11 @@ type RemoteWorkspaceTarget = { identityFile?: string | null } +type RosNetworkConfig = { + enabled?: boolean | null + peer?: RemoteWorkspaceTarget | null +} + type VisualizationStack = 'autoware' | 'nav2' | 'slam' type VisualizationBridgeStatus = { @@ -216,6 +239,19 @@ type WorldLayout = { objects: WorldLayoutObject[] } +type SavedMapArtifactName = 'yaml' | 'pgm' | 'database' + +type SavedMapArtifactPath = { + name: SavedMapArtifactName + path: string +} + +type SavedMapArtifactDownload = { + name: SavedMapArtifactName + fileName: string + url: string +} + function defaultWorldLayout(): WorldLayout { return { version: 1, @@ -251,6 +287,76 @@ function worldLayoutPath(workspaceRoot?: string | null): string { return resolve(root, 'world-layout.json') } +function safeSavedMapFileName(input: string): string { + const fileName = basename(input.trim()) + if (!fileName || fileName === '.' || fileName === '..' || !/^[A-Za-z0-9._-]+$/.test(fileName)) { + throw new Error('저장된 맵 파일명이 올바르지 않습니다.') + } + return fileName +} + +function savedMapArtifactFileName(name: SavedMapArtifactName, path: string): string { + const fileName = safeSavedMapFileName(path) + const expectedExtension = name === 'yaml' ? '.yaml' : name === 'pgm' ? '.pgm' : '.db' + if (!fileName.endsWith(expectedExtension)) throw new Error('저장된 맵 파일 형식이 올바르지 않습니다.') + return fileName +} + +function validateSavedMapDownload(input: SavedMapArtifactDownload): SavedMapArtifactDownload { + const fileName = savedMapArtifactFileName(input.name, input.fileName) + const parsed = new URL(input.url) + if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('저장된 맵 다운로드 주소가 올바르지 않습니다.') + return { ...input, fileName, url: parsed.toString() } +} + +async function readWorkspaceSavedMapArtifacts( + workspaceRoot: string | null | undefined, + artifacts: SavedMapArtifactPath[], +): Promise> { + const root = expandUserPath(workspaceRoot) + if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') + return Promise.all(artifacts.map(async (artifact) => { + const fileName = savedMapArtifactFileName(artifact.name, artifact.path) + const filePath = resolve(root, 'maps', fileName) + if (!filePath.startsWith(`${resolve(root, 'maps')}/`)) throw new Error('저장된 맵 경로가 올바르지 않습니다.') + return { name: artifact.name, fileName, base64: (await readFile(filePath)).toString('base64') } + })) +} + +async function syncWorkspaceSavedMapArtifacts( + workspaceRoot: string | null | undefined, + artifacts: SavedMapArtifactDownload[], +): Promise<{ ok: boolean }> { + const root = expandUserPath(workspaceRoot) + if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') + const mapsDirectory = resolve(root, 'maps') + await mkdir(mapsDirectory, { recursive: true }) + const probePath = resolve(mapsDirectory, `.autonomy-studio-write-${process.pid}`) + try { + await writeFile(probePath, '') + await rm(probePath, { force: true }) + } catch (error) { + await rm(probePath, { force: true }).catch(() => undefined) + if (!isPermissionError(error)) throw error + const image = await resolveWorkspaceCleanupImage() + if (!image) throw new Error('저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다.') + const userId = typeof process.getuid === 'function' ? process.getuid() : 1000 + const groupId = typeof process.getgid === 'function' ? process.getgid() : 1000 + await runCommand('docker', [ + 'run', '--rm', '--user', '0:0', + '-v', `${root}:/workspace`, + '--entrypoint', '/bin/sh', + image, + '-lc', `mkdir -p /workspace/maps && chown -R ${userId}:${groupId} /workspace/maps`, + ], undefined, undefined, 30_000) + } + await Promise.all(artifacts.map(async (artifact) => { + const normalized = validateSavedMapDownload(artifact) + await downloadArtifact(normalized.url, resolve(mapsDirectory, normalized.fileName)) + })) + return { ok: true } +} + type LearningVideoInput = { url?: string | null title?: string | null @@ -548,8 +654,96 @@ async function createRemoteCustomWorkspaceModule( return { ok: true, workspacePath: output.trim().split('\n')[0] || remotePathJoin(target.workspaceRoot, module.folderName) } } -function resolveNav2RuntimeImage(runtimeImages?: RuntimeImageArtifact[] | null, folderName?: string | null): string { - const gazeboModule = isGazeboNav2ModuleFolder(folderName) +async function transferLocalWorkspaceModuleToRemote( + targetInput: RemoteWorkspaceTarget, + sourceWorkspaceRoot: string | null | undefined, + moduleName: string, + folderName?: string | null, +): Promise<{ ok: boolean; workspacePath: string }> { + const target = normalizeRemoteTarget(targetInput) + const sourceModuleRoot = await findWorkspaceModuleRoot(moduleName, sourceWorkspaceRoot, folderName) + if (!sourceModuleRoot) throw new Error(`전송할 로컬 모듈을 찾을 수 없습니다: ${moduleName}`) + const sourceStats = await stat(sourceModuleRoot) + if (!sourceStats.isDirectory()) throw new Error(`전송할 로컬 모듈 경로가 올바르지 않습니다: ${moduleName}`) + const resolvedFolderName = basename(sourceModuleRoot) + if (!resolvedFolderName || resolvedFolderName === '.' || resolvedFolderName === '..') { + throw new Error('전송할 모듈 폴더명이 올바르지 않습니다.') + } + + const remoteCommand = [ + 'set -e', + `ROOT=${quoteShellSingle(target.workspaceRoot)}`, + `FOLDER=${quoteShellSingle(resolvedFolderName)}`, + ...expandRemotePathVar('ROOT'), + 'STAGING="$ROOT/.incoming-$FOLDER"', + 'rm -rf "$STAGING"', + 'mkdir -p "$STAGING"', + 'tar -xf - -C "$STAGING"', + 'rm -rf "$ROOT/$FOLDER"', + 'mv "$STAGING" "$ROOT/$FOLDER"', + 'printf %s "$ROOT/$FOLDER"', + ].join('\n') + const sshArgs = [ + '-p', String(target.port), + '-o', 'BatchMode=yes', + '-o', 'StrictHostKeyChecking=accept-new', + '-o', 'ConnectTimeout=5', + '-i', target.identityFile, + `${target.user}@${target.host}`, + remoteCommand, + ] + + await new Promise((resolvePromise, rejectPromise) => { + let output = '' + let settled = false + const finish = (error?: Error): void => { + if (settled) return + settled = true + clearTimeout(timeout) + if (error) rejectPromise(error) + else resolvePromise() + } + const archive = spawn('tar', [ + '--exclude=.git', + '--exclude=build', + '--exclude=install', + '--exclude=log', + '--exclude=__pycache__', + '-C', sourceModuleRoot, + '-cf', '-', + '.', + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + const remote = spawn('ssh', sshArgs, { stdio: ['pipe', 'pipe', 'pipe'] }) + const timeout = setTimeout(() => { + archive.kill('SIGTERM') + remote.kill('SIGTERM') + finish(new Error('모듈 전송 시간이 초과되었습니다.')) + }, 120_000) + archive.stdout.pipe(remote.stdin) + archive.stderr.on('data', (data: Buffer) => { output += data.toString() }) + remote.stdout.on('data', (data: Buffer) => { output += data.toString() }) + remote.stderr.on('data', (data: Buffer) => { output += data.toString() }) + archive.on('error', (error) => finish(error)) + remote.on('error', (error) => finish(error)) + remote.on('close', (code) => { + if (code === 0) finish() + else finish(new Error(output.trim() || `모듈 전송에 실패했습니다. (ssh exit ${code ?? 'unknown'})`)) + }) + }) + + return { ok: true, workspacePath: remotePathJoin(target.workspaceRoot, resolvedFolderName) } +} + +function resolveNav2RuntimeImage( + runtimeImages?: RuntimeImageArtifact[] | null, + folderName?: string | null, + rosParams = '', +): string { + // NAV2-MKMini normally targets the real platform image. Gazebo is an + // explicit local opt-in, so existing platform launches keep their image. + const mkminiGazebo = /lms-nav2-mkmini/i.test(folderName ?? '') + && /(?:^|\s)simulator:=gazebo(?:\s|$)/.test(rosParams) + const gazeboModule = isGazeboNav2ModuleFolder(folderName) || mkminiGazebo const gazeboImage = runtimeImages ?.find((image) => /nav2.*gazebo|gazebo.*nav2/i.test(image?.image?.trim() ?? '')) ?.image @@ -562,8 +756,8 @@ function resolveNav2RuntimeImage(runtimeImages?: RuntimeImageArtifact[] | null, ?.image ?.trim() - // Platform profiles do not ship Gazebo. Their shared NAV2 image must win - // even when a module folder was originally created for simulation. + // Only Gazebo module folders and the explicit local MK-mini Gazebo option + // use the Gazebo runtime image. Platform runs keep the shared NAV2 image. if (gazeboModule) return gazeboImage || nav2Image || 'future-nav2-gazebo:humble' return nav2Image || nav2DockerImage } @@ -719,6 +913,7 @@ async function collectAvailableModules( dockerImage: module.dockerImage ?? null, packageName: module.packageName ?? null, launchFile: module.launchFile ?? null, + runtimeTarget: module.runtimeTarget ?? null, parameters: splitRosParams(rosParams), cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, @@ -798,6 +993,7 @@ async function collectRemoteAvailableModules( dockerImage: module.dockerImage ?? null, packageName: module.packageName ?? null, launchFile: module.launchFile ?? null, + runtimeTarget: module.runtimeTarget ?? null, parameters: installed?.parameters ?? [], cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, @@ -991,7 +1187,12 @@ async function extractArtifact(filePath: string, targetRoot: string, extractPath } async function applyModulePrebuiltArtifacts(targetRoot: string, artifacts?: ModulePrebuiltArtifact[] | null): Promise { - const normalizedArtifacts = (artifacts ?? []).filter((artifact) => artifact?.url?.trim()) + const architecture = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch + const normalizedArtifacts = (artifacts ?? []).filter((artifact) => { + if (!artifact?.url?.trim()) return false + const targetArchitecture = artifact.architecture?.trim().toLowerCase() + return !targetArchitecture || targetArchitecture === architecture + }) for (const artifact of normalizedArtifacts) { const filePath = await ensureArtifactFile(artifact.url, artifact.fileName, artifact.checksumSha256) if (!filePath) { @@ -1109,6 +1310,49 @@ async function runSsh(target: RemoteWorkspaceTarget, command: string, timeoutMs? await runSshOutput(target, command, timeoutMs) } +async function readRemoteSavedMapArtifacts( + targetInput: RemoteWorkspaceTarget, + artifacts: SavedMapArtifactPath[], +): Promise> { + const target = normalizeRemoteTarget(targetInput) + return Promise.all(artifacts.map(async (artifact) => { + const fileName = savedMapArtifactFileName(artifact.name, artifact.path) + const mapPath = `${target.workspaceRoot}/maps/${fileName}` + const base64 = (await runSshOutput( + target, + `test -f ${quoteShellSingle(mapPath)} && base64 -w0 ${quoteShellSingle(mapPath)}`, + 120_000, + )).trim() + if (!base64) throw new Error(`저장된 맵 파일을 읽을 수 없습니다: ${fileName}`) + return { name: artifact.name, fileName, base64 } + })) +} + +async function syncRemoteSavedMapArtifacts( + targetInput: RemoteWorkspaceTarget, + artifacts: SavedMapArtifactDownload[], +): Promise<{ ok: boolean }> { + const target = normalizeRemoteTarget(targetInput) + const downloads = artifacts.map(validateSavedMapDownload) + const mapsDirectory = `${target.workspaceRoot}/maps` + const command = [ + 'set -e', + `ROOT=${quoteShellSingle(target.workspaceRoot)}`, + `MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`, + 'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then', + ' IMAGE="$(docker image inspect future-nav2:humble-jetson_orin --format {{.Id}} >/dev/null 2>&1 && printf future-nav2:humble-jetson_orin || docker image inspect future-nav2:humble --format {{.Id}} >/dev/null 2>&1 && printf future-nav2:humble || true)"', + ' if [ -z "$IMAGE" ]; then echo "저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다." >&2; exit 1; fi', + ' docker run --rm --user 0:0 -v "$ROOT:/workspace" --entrypoint /bin/sh "$IMAGE" -lc "mkdir -p /workspace/maps && chown -R $(id -u):$(id -g) /workspace/maps"', + 'fi', + 'mkdir -p "$MAPS_DIRECTORY"', + ...downloads.map((artifact) => ( + `curl -fsSL --retry 2 ${quoteShellSingle(artifact.url)} -o ${quoteShellSingle(`${mapsDirectory}/${artifact.fileName}`)}` + )), + ].join('\n') + await runSsh(target, command, 120_000) + return { ok: true } +} + async function readRemoteVisualizationBridgeStatus(target: RemoteWorkspaceTarget): Promise { try { const output = await runSshOutput( @@ -1131,11 +1375,13 @@ async function startRemoteVisualizationBridge( stackInput?: string | null, rosDomainIdInput?: string | null, environmentProfile?: EnvironmentProfileConfig | null, + rosNetwork?: RosNetworkConfig | null, ): Promise { const stack = normalizeVisualizationStack(stackInput) const rosDomainId = String(rosDomainIdInput ?? '0').trim() || '0' const normalized = normalizeRemoteTarget(target) const root = normalized.workspaceRoot + const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(normalized, '~/.autonomy-studio/runtime/nav2', rosNetwork, rosDomainId) const containerName = visualizationBridgeContainerName(stack) const bridgeEnvArgs = Object.entries(environmentProfile?.bridge?.env ?? {}) .filter(([key]) => /^[A-Z_][A-Z0-9_]*$/.test(key)) @@ -1191,6 +1437,8 @@ async function startRemoteVisualizationBridge( 'set -e', `ROOT=${quoteShellSingle(root)}`, ...expandRemotePathVar('ROOT'), + `FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`, + ...expandRemotePathVar('FASTDDS_CONFIG_PATH'), `ROS_DOMAIN_ID=${quoteShellSingle(rosDomainId)}`, `CONTAINER=${quoteShellSingle(containerName)}`, 'docker rm -f nav2-ros-bridge autoware-ros-bridge slam-ros-bridge >/dev/null 2>&1 || true', @@ -1199,6 +1447,7 @@ async function startRemoteVisualizationBridge( 'docker run -d --name "$CONTAINER" --net=host', '-v /dev/shm:/dev/shm', '-v "$ROOT:/workspace"', + ...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []), '-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"', '-e ROS_LOCALHOST_ONLY=0', `-e ROBOT_STACK=${quoteShellSingle(stack === 'autoware' ? 'autoware' : 'nav2')}`, @@ -1236,7 +1485,7 @@ function expandRemotePathVar(varName: string): string[] { return [ `case "$${varName}" in`, ` "~") ${varName}="$HOME" ;;`, - ` "~/"*) ${varName}="$HOME/\${${varName}#~/}" ;;`, + ` "~/"*) ${varName}="$HOME/\${${varName}:2}" ;;`, 'esac' ] } @@ -1358,6 +1607,10 @@ function parseGitStatus(stdout: string): GitChangedFile[] { function parseGitBranch(stdout: string): { branch: string | null; ahead: number; behind: number } { const firstLine = stdout.split('\n').find((line) => line.startsWith('## ')) ?? '' + const unbornBranchMatch = firstLine.match(/^##\s+No commits yet on\s+(.+)$/) + if (unbornBranchMatch?.[1]) { + return { branch: unbornBranchMatch[1].trim(), ahead: 0, behind: 0 } + } const branchMatch = firstLine.match(/^##\s+([^\s.]+|HEAD)(?:\.\.\.[^\s]+)?(?:\s+\[(.+)\])?/) const statusText = branchMatch?.[2] ?? '' const ahead = Number(statusText.match(/ahead\s+(\d+)/)?.[1] ?? 0) @@ -1670,10 +1923,22 @@ async function submitWorkspaceGitChanges( message?: string | null, authorName?: string | null, authorEmail?: string | null, - submissionId?: string | null + submissionId?: string | null, + repositoryUrl?: string | null, ): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { try { const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) + const remoteUrl = repositoryUrl?.trim() + if (remoteUrl) { + const remotes = (await runCommandOutput('git', ['remote'], moduleRoot, undefined, 8000)) + .split('\n') + .map((item) => item.trim()) + if (remotes.includes('origin')) { + await runCommand('git', ['remote', 'set-url', 'origin', remoteUrl], moduleRoot, undefined, 8000) + } else { + await runCommand('git', ['remote', 'add', 'origin', remoteUrl], moduleRoot, undefined, 8000) + } + } const statusOutput = await runCommandOutput('git', ['status', '--porcelain=v1', '-uall', ...workspaceGitPathspec], moduleRoot, undefined, 8000) const changedFiles = parseGitStatus(statusOutput) if (!changedFiles.length) return { ok: false, message: '제출할 변경사항이 없습니다.' } @@ -1708,6 +1973,71 @@ async function submitWorkspaceGitChanges( } } +async function publishWorkspaceTeamGitChanges( + moduleName: string, + workspaceRoot?: string | null, + folderName?: string | null, + message?: string | null, + authorName?: string | null, + authorEmail?: string | null, +): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { + try { + const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) + const statusOutput = await runCommandOutput('git', ['status', '--porcelain=v1', '-uall', ...workspaceGitPathspec], moduleRoot, undefined, 8000) + const changedFiles = parseGitStatus(statusOutput) + if (!changedFiles.length) return { ok: false, message: '팀에 반영할 변경사항이 없습니다.' } + + const branch = (await runCommandOutput('git', ['branch', '--show-current'], moduleRoot, undefined, 8000)).trim() + if (!branch) throw new Error('현재 Git 브랜치를 확인하지 못했습니다.') + const remote = (await runCommandOutput('git', ['remote'], moduleRoot, undefined, 8000)) + .split('\n') + .map((item) => item.trim()) + .includes('student') ? 'student' : 'origin' + const remoteBranch = remote === 'student' ? 'main' : branch + const commitMessage = message?.trim() || `${moduleName} 팀 반영` + const name = authorName?.trim() || process.env.STUDIO_GIT_AUTHOR_NAME?.trim() || 'Autonomy Studio LMS' + const email = authorEmail?.trim() || process.env.STUDIO_GIT_AUTHOR_EMAIL?.trim() || 'lms@futuredrive.net' + + await runCommand('git', ['add', '-A', '--', ...changedFiles.map((file) => file.path)], moduleRoot, undefined, 15000) + await runCommand('git', ['-c', `user.name=${name}`, '-c', `user.email=${email}`, 'commit', '-m', commitMessage], moduleRoot, undefined, 30000) + await runCommand('git', ['fetch', remote, '--prune'], moduleRoot, undefined, 60000) + await runCommand('git', ['rebase', `${remote}/${remoteBranch}`], moduleRoot, undefined, 60000) + await runCommand('git', ['push', '-u', remote, `HEAD:${remoteBranch}`], moduleRoot, undefined, 120000) + + const commitOutput = await runCommandOutput('git', ['log', '-1', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict'], moduleRoot, undefined, 8000) + return { ok: true, message: '팀 저장소에 반영했습니다.', commit: parseGitCommits(commitOutput)[0] } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } +} + +async function pullWorkspaceTeamGitChanges( + moduleName: string, + workspaceRoot?: string | null, + folderName?: string | null, +): Promise<{ ok: boolean; message: string }> { + try { + const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) + const statusOutput = await runCommandOutput('git', ['status', '--porcelain=v1', '-uall'], moduleRoot, undefined, 8000) + if (statusOutput.trim()) { + throw new Error('로컬 변경사항이 있습니다. 먼저 팀에 반영하거나 변경사항을 되돌린 뒤 다시 시도하세요.') + } + + const branch = (await runCommandOutput('git', ['branch', '--show-current'], moduleRoot, undefined, 8000)).trim() + if (!branch) throw new Error('현재 Git 브랜치를 확인하지 못했습니다.') + const remote = (await runCommandOutput('git', ['remote'], moduleRoot, undefined, 8000)) + .split('\n') + .map((item) => item.trim()) + .includes('student') ? 'student' : 'origin' + const remoteBranch = remote === 'student' ? 'main' : branch + await runCommand('git', ['fetch', remote, '--prune'], moduleRoot, undefined, 60000) + await runCommand('git', ['rebase', `${remote}/${remoteBranch}`], moduleRoot, undefined, 60000) + return { ok: true, message: '팀 변경사항을 받았습니다.' } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } +} + async function restoreWorkspaceGitFile( moduleName: string, workspaceRoot?: string | null, @@ -1772,7 +2102,8 @@ async function submitRemoteWorkspaceGitChanges( message?: string | null, authorName?: string | null, authorEmail?: string | null, - submissionId?: string | null + submissionId?: string | null, + repositoryUrl?: string | null, ): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { try { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) @@ -1787,6 +2118,12 @@ async function submitRemoteWorkspaceGitChanges( const script = [ 'set -e', `TARGET=${quoteShellSingle(moduleRoot)}`, + ...(repositoryUrl?.trim() + ? [ + `REPOSITORY_URL=${quoteShellSingle(repositoryUrl.trim())}`, + 'if git -C "$TARGET" remote get-url origin >/dev/null 2>&1; then git -C "$TARGET" remote set-url origin "$REPOSITORY_URL"; else git -C "$TARGET" remote add origin "$REPOSITORY_URL"; fi', + ] + : []), `git -C "$TARGET" add -A -- ${shellArgs(addFiles)}`, `git -C "$TARGET" -c ${quoteShellSingle(`user.name=${name}`)} -c ${quoteShellSingle(`user.email=${email}`)} commit -m ${quoteShellSingle(commitMessage)}`, 'PUSH_REMOTE="$(git -C "$TARGET" remote get-url student >/dev/null 2>&1 && printf student || printf origin)"', @@ -1817,6 +2154,79 @@ async function submitRemoteWorkspaceGitChanges( } } +async function publishRemoteWorkspaceTeamGitChanges( + targetInput: RemoteWorkspaceTarget, + moduleName: string, + folderName?: string | null, + message?: string | null, + authorName?: string | null, + authorEmail?: string | null, +): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { + try { + const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) + const commitMessage = message?.trim() || `${moduleName} 팀 반영` + const name = authorName?.trim() || process.env.STUDIO_GIT_AUTHOR_NAME?.trim() || 'Autonomy Studio LMS' + const email = authorEmail?.trim() || process.env.STUDIO_GIT_AUTHOR_EMAIL?.trim() || 'lms@futuredrive.net' + const script = [ + 'set -e', + `TARGET=${quoteShellSingle(moduleRoot)}`, + `MESSAGE=${quoteShellSingle(commitMessage)}`, + `AUTHOR_NAME=${quoteShellSingle(name)}`, + `AUTHOR_EMAIL=${quoteShellSingle(email)}`, + `STATUS="$(git -C "$TARGET" status --porcelain=v1 -uall ${shellArgs(workspaceGitPathspec)})"`, + 'if [ -z "$STATUS" ]; then echo "NO_CHANGES"; exit 0; fi', + 'BRANCH="$(git -C "$TARGET" branch --show-current)"', + 'test -n "$BRANCH"', + 'REMOTE="$(git -C "$TARGET" remote get-url student >/dev/null 2>&1 && printf student || printf origin)"', + 'REMOTE_BRANCH="$BRANCH"', + 'if [ "$REMOTE" = "student" ]; then REMOTE_BRANCH="main"; fi', + `git -C "$TARGET" add -A -- ${shellArgs(workspaceGitPathspec)}`, + 'git -C "$TARGET" -c "user.name=$AUTHOR_NAME" -c "user.email=$AUTHOR_EMAIL" commit -m "$MESSAGE"', + 'git -C "$TARGET" fetch "$REMOTE" --prune', + 'git -C "$TARGET" rebase "$REMOTE/$REMOTE_BRANCH"', + 'git -C "$TARGET" push -u "$REMOTE" "HEAD:$REMOTE_BRANCH"', + 'git -C "$TARGET" log -1 --pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s --date=iso-strict | base64 -w0' + ].join('\n') + const output = (await runSshOutput(target, script, 180000)).trim() + if (output === 'NO_CHANGES') return { ok: false, message: '팀에 반영할 변경사항이 없습니다.' } + return { ok: true, message: '팀 저장소에 반영했습니다.', commit: parseGitCommits(decodeRemoteField(output))[0] } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } +} + +async function pullRemoteWorkspaceTeamGitChanges( + targetInput: RemoteWorkspaceTarget, + moduleName: string, + folderName?: string | null, +): Promise<{ ok: boolean; message: string }> { + try { + const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) + const script = [ + 'set -e', + `TARGET=${quoteShellSingle(moduleRoot)}`, + 'if [ -n "$(git -C "$TARGET" status --porcelain=v1 -uall)" ]; then', + ' echo "LOCAL_CHANGES"', + ' exit 0', + 'fi', + 'BRANCH="$(git -C "$TARGET" branch --show-current)"', + 'test -n "$BRANCH"', + 'REMOTE="$(git -C "$TARGET" remote get-url student >/dev/null 2>&1 && printf student || printf origin)"', + 'REMOTE_BRANCH="$BRANCH"', + 'if [ "$REMOTE" = "student" ]; then REMOTE_BRANCH="main"; fi', + 'git -C "$TARGET" fetch "$REMOTE" --prune', + 'git -C "$TARGET" rebase "$REMOTE/$REMOTE_BRANCH"' + ].join('\n') + const output = (await runSshOutput(target, script, 90000)).trim() + if (output === 'LOCAL_CHANGES') { + return { ok: false, message: '로컬 변경사항이 있습니다. 먼저 팀에 반영하거나 변경사항을 되돌린 뒤 다시 시도하세요.' } + } + return { ok: true, message: '팀 변경사항을 받았습니다.' } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } +} + async function restoreRemoteWorkspaceGitFile( targetInput: RemoteWorkspaceTarget, moduleName: string, @@ -2083,17 +2493,32 @@ async function findPlanningWorkspacePath(workspaceRoot?: string | null): Promise function resolveResourceContextPath(contextPath = 'resources/slam-ros-bridge'): string | null { const normalizedContextPath = contextPath.replace(/^\/+/, '') const candidates = [ - join(app.getAppPath(), normalizedContextPath), join(process.resourcesPath, normalizedContextPath.replace(/^resources\//, '')), + ...(app.isPackaged ? [] : [join(app.getAppPath(), normalizedContextPath)]), join(process.cwd(), normalizedContextPath) ] return candidates.find((candidate) => existsSync(join(candidate, 'Dockerfile'))) ?? null } +async function resolveDockerResourceContextPath(contextPath = 'resources/slam-ros-bridge'): Promise { + const sourcePath = resolveResourceContextPath(contextPath) + if (!sourcePath) return null + + // Docker's daemon cannot access an AppImage FUSE mount under /tmp/.mount_*. + // Stage the small bridge context in userData before using it as a build context or bind mount. + if (!app.isPackaged || !sourcePath.startsWith(`${process.resourcesPath}/`)) return sourcePath + + const targetPath = join(app.getPath('userData'), 'runtime', 'docker-contexts', basename(sourcePath)) + await rm(targetPath, { recursive: true, force: true }) + await mkdir(dirname(targetPath), { recursive: true }) + await cp(sourcePath, targetPath, { recursive: true, force: true }) + return targetPath +} + async function ensureBridgeImage(image: string, buildContext?: string | null): Promise { if (!buildContext) return - const contextPath = resolveResourceContextPath(buildContext) + const contextPath = await resolveDockerResourceContextPath(buildContext) if (!contextPath) throw new Error(`브릿지 Docker context를 찾을 수 없습니다: ${buildContext}`) await runCommand('docker', ['build', '-t', image, contextPath], undefined, undefined, 120000) } @@ -2196,7 +2621,9 @@ async function readVisualizationBridgeStatus(): Promise { const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase() ?? stackInput) const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack) @@ -2314,6 +2742,9 @@ async function startVisualizationBridge( const bridgePort = bridge?.port ?? rosBridgePort const usesSlamBridge = stack === 'slam' || stack === 'slam3d' || stack === 'nav2' const requestedRosDomainId = String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0' + const requestedRosNetworkKey = rosNetworkEnabled(rosNetwork) + ? `peer:${rosNetwork?.peer?.host?.trim() ?? ''}` + : 'local' const otherContainerNames = (['autoware', 'nav2', 'slam'] as VisualizationStack[]) .filter((item) => item !== stack) .map(visualizationBridgeContainerName) @@ -2323,7 +2754,8 @@ async function startVisualizationBridge( return await callRemoteVisualizationAgent(resolvedProfile, 'start', { stack, workspaceRoot, - rosDomainId: requestedRosDomainId + rosDomainId: requestedRosDomainId, + rosNetwork, }) } @@ -2331,7 +2763,10 @@ async function startVisualizationBridge( if (statuses.get(containerName)?.running) { const response = await fetch(`http://localhost:${bridgePort}/${usesSlamBridge ? 'health' : 'stack'}`).catch(() => null) const runningRosDomainId = await readContainerEnvironmentValue(containerName, 'ROS_DOMAIN_ID') - if (response?.ok && (!runningRosDomainId || runningRosDomainId === requestedRosDomainId)) { + const runningRosNetworkKey = await readContainerEnvironmentValue(containerName, 'AUTONOMY_STUDIO_ROS_NETWORK') + if (response?.ok + && (!runningRosDomainId || runningRosDomainId === requestedRosDomainId) + && runningRosNetworkKey === requestedRosNetworkKey) { return readVisualizationBridgeStatus() } await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined) @@ -2355,7 +2790,7 @@ async function startVisualizationBridge( } await ensureEnvironmentRuntime(runtimeProfile, requestedRosDomainId) } - const bridgeContextPath = resolveResourceContextPath(visualizationBridgeResourceContext(stack, bridge) ?? undefined) + const bridgeContextPath = await resolveDockerResourceContextPath(visualizationBridgeResourceContext(stack, bridge) ?? undefined) if (!bridgeContextPath) throw new Error(`브릿지 리소스를 찾을 수 없습니다: ${visualizationBridgeResourceContext(stack, bridge) ?? stack}`) const bridgeImageExists = await runCommand('docker', ['image', 'inspect', bridgeImage], undefined, undefined, 10000) .then(() => true) @@ -2379,6 +2814,8 @@ async function startVisualizationBridge( '-e', 'ROS_LOCALHOST_ONLY=0', '-e', + `AUTONOMY_STUDIO_ROS_NETWORK=${requestedRosNetworkKey}`, + '-e', `ROBOT_STACK=${stack}`, '-e', 'RCUTILS_COLORIZED_OUTPUT=1', @@ -2397,9 +2834,13 @@ async function startVisualizationBridge( ) if (stack === 'slam3d') args.push('-e', 'RTABMAP_MAP_SAVE_ENABLED=true') } - const cycloneConfigPath = join(studioRuntimeRoot, 'docker', 'cyclonedds_config.xml') - if (existsSync(cycloneConfigPath)) { - args.push('-v', `${cycloneConfigPath}:/cyclonedds_config.xml:ro`, '-e', 'CYCLONEDDS_URI=file:///cyclonedds_config.xml') + const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, requestedRosDomainId) + if (fastDdsConfigPath && existsSync(fastDdsConfigPath)) { + args.push( + '-v', `${fastDdsConfigPath}:/fastdds_profiles.xml:ro`, + '-e', 'FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', + '-e', 'FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', + ) } if (stack === 'autoware') { @@ -2594,6 +3035,7 @@ async function installRemoteWorkspaceModule( fileName: safeArtifactFileName(artifact.url?.trim() ?? '', artifact.fileName), checksumSha256: artifact.checksumSha256?.trim() ?? '', extractPath: artifact.extractPath?.trim() ?? '.', + architecture: artifact.architecture?.trim().toLowerCase() ?? '', required: Boolean(artifact.required), })) const prebuiltJson = JSON.stringify(prebuiltArtifacts) @@ -2631,6 +3073,8 @@ async function installRemoteWorkspaceModule( 'python3 - "$PREBUILT_JSON" "$ARTIFACT_CACHE" "$TARGET" <<\'PY\'', 'import json, os, subprocess, sys', 'artifacts = json.loads(sys.argv[1] or "[]")', + 'machine = os.uname().machine.lower()', + 'architecture = "arm64" if machine in {"aarch64", "arm64"} else "amd64" if machine in {"x86_64", "amd64"} else machine', 'cache = sys.argv[2]', 'target = sys.argv[3]', 'def tar_args(path, mode):', @@ -2639,6 +3083,8 @@ async function installRemoteWorkspaceModule( ' if lower.endswith(".tar.gz") or lower.endswith(".tgz"): return ["-tzf" if mode == "list" else "-xzf"]', ' return ["-tf" if mode == "list" else "-xf"]', 'for item in artifacts:', + ' target_architecture = (item.get("architecture") or "").strip().lower()', + ' if target_architecture and target_architecture != architecture: continue', ' url = (item.get("url") or "").strip()', ' if not url: continue', ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"', @@ -2756,6 +3202,8 @@ function buildAutowareDockerRunScript(): string { 'ROS_DOMAIN_ID=$7', 'GPU_ENABLED=${8:-false}', 'BUILD_BEFORE_RUN=${9:-false}', + 'CYCLONE_CONFIG_PATH=${CYCLONEDDS_CONFIG_PATH:-./docker/cyclonedds_config.xml}', + 'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}', 'GPU_ARGS=()', 'GL_ARGS=(-e LIBGL_ALWAYS_SOFTWARE=1)', 'CONTAINER_COMMAND=\'cd /autoware && bash /autoware/run.sh "$@"\'', @@ -2778,8 +3226,8 @@ function buildAutowareDockerRunScript(): string { ' -v /tmp/.X11-unix:/tmp/.X11-unix \\', ' -v /etc/localtime:/etc/localtime:ro \\', ' -e RCUTILS_COLORIZED_OUTPUT=1 \\', - ' -e ROS_LOCALHOST_ONLY=1 \\', - ' -v ./docker/cyclonedds_config.xml:/cyclonedds_config.xml \\', + ' -e ROS_LOCALHOST_ONLY="$ROS_LOCALHOST_ONLY_VALUE" \\', + ' -v "$CYCLONE_CONFIG_PATH:/cyclonedds_config.xml" \\', ' -e CYCLONEDDS_URI=file:///cyclonedds_config.xml \\', ' -v /dev/bus/usb:/dev/bus/usb \\', ' --device /dev/snd \\', @@ -2791,6 +3239,71 @@ function buildAutowareDockerRunScript(): string { ].join('\n') } +function parseRosDomainId(input?: string | null): number { + const value = Number.parseInt(String(input ?? '0').trim(), 10) + return Number.isInteger(value) && value >= 0 && value <= 232 ? value : 0 +} + +function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDomainId?: string | null): string { + const domainId = parseRosDomainId(rosDomainId) + const discoveryBasePort = 7400 + (250 * domainId) + // Fast DDS can bind the default discovery port itself (d0), while other + // participants use d1 + 2 * participantId. Include both forms so a peer + // remains discoverable regardless of its participant ID allocation. + const peers = [ + discoveryBasePort, + ...Array.from({ length: 121 }, (_, participantId) => discoveryBasePort + 10 + (2 * participantId)) + ] + const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress])) + return [ + '', + '', + ' ', + ' ', + ' autonomy_studio_udp', + ' UDPv4', + ' 1048576', + ' 1048576', + ' 1200', + ' ', + '

127.0.0.1
', + `
${localAddress}
`, + ' ', + ' ', + ' ', + ' ', + ' ', + ' false', + ' ', + ' autonomy_studio_udp', + ' ', + ' ', + ' ', + ' ', + ' ', + `
${localAddress}
`, + ' 0', + '
', + '
', + '
', + ' ', + ...peerAddresses.flatMap((address) => peers.flatMap((port) => [ + ' ', + ' ', + `
${address}
`, + ` ${port}`, + '
', + '
', + ])), + '
', + '
', + '
', + '
', + '', + '' + ].join('\n') +} + function buildCycloneDdsConfig(): string { return [ '', @@ -2818,6 +3331,70 @@ function buildCycloneDdsConfig(): string { ].join('\n') } +function resolveTailscaleIpv4(output: string): string { + const address = output.split(/\s+/).find((value) => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) + if (!address) throw new Error('Tailscale IPv4 주소를 확인하지 못했습니다.') + return address +} + +async function readLocalTailscaleIpv4(): Promise { + const output = await runCommandOutput('tailscale', ['ip', '-4'], undefined, undefined, 10_000) + return resolveTailscaleIpv4(output) +} + +async function readRemoteTailscaleIpv4(target: RemoteWorkspaceTarget): Promise { + const output = await runSshOutput(target, 'tailscale ip -4 2>/dev/null | head -n 1', 15_000) + return resolveTailscaleIpv4(output) +} + +function rosNetworkEnabled(network?: RosNetworkConfig | null): boolean { + return network?.enabled === true +} + +async function prepareLocalRosNetworkConfig( + network?: RosNetworkConfig | null, + rosDomainId?: string | null, +): Promise { + if (!rosNetworkEnabled(network)) return null + if (!network?.peer?.host) throw new Error('연동 대상 플랫폼의 SSH 주소를 확인하지 못했습니다.') + + const requestedPeerHost = network.peer.host.trim() + const [peerAddress, localAddress] = await Promise.all([ + /^(?:\d{1,3}\.){3}\d{1,3}$/.test(requestedPeerHost) + ? Promise.resolve(requestedPeerHost) + : readRemoteTailscaleIpv4(network.peer), + readLocalTailscaleIpv4(), + ]) + const dockerRoot = resolveNav2RuntimeDockerRoot() + await mkdir(dockerRoot, { recursive: true }) + const configPath = join(dockerRoot, `fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}.xml`) + await writeFile(configPath, buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId), 'utf8') + return configPath +} + +async function prepareRemoteRosNetworkConfig( + target: RemoteWorkspaceTarget, + runtimeRoot: string, + network?: RosNetworkConfig | null, + rosDomainId?: string | null, +): Promise { + if (!rosNetworkEnabled(network)) return null + + const [peerAddress, localAddress] = await Promise.all([ + readLocalTailscaleIpv4(), + readRemoteTailscaleIpv4(target), + ]) + const configPath = `${runtimeRoot}/docker/fastdds-peer-${peerAddress.replace(/[^0-9]/g, '-')}-d${parseRosDomainId(rosDomainId)}.xml` + await runSsh(target, [ + 'set -e', + `CONFIG_PATH=${quoteShellSingle(configPath)}`, + ...expandRemotePathVar('CONFIG_PATH'), + 'mkdir -p "$(dirname \"$CONFIG_PATH\")"', + `printf %s ${quoteShellSingle(encodeBase64(buildFastDdsPeerConfig(peerAddress, localAddress, rosDomainId)))} | base64 -d > "$CONFIG_PATH"`, + ].join('\n'), 15_000) + return configPath +} + function resolveNav2RuntimeDockerRoot(): string { return resolve(studioRuntimeRoot, 'nav2', 'docker') } @@ -2882,7 +3459,9 @@ function buildNav2DockerRunScript(): string { 'MODULE_FOLDER=$3', 'IMAGE=$4', 'ROS_DOMAIN_ID_VALUE=${5:-0}', - 'shift 5 || true', + 'GPU_ENABLED=${6:-false}', + 'shift 6 || true', + 'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}', 'LOCALIZATION_MODE=slam', 'filtered_args=()', 'for arg in "$@"; do', @@ -2950,6 +3529,12 @@ function buildNav2DockerRunScript(): string { ' -v "$(dirname "$0"):/runtime/nav2/docker:ro"', ' -v /etc/localtime:/etc/localtime:ro', ')', + 'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then', + ' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)', + 'fi', + 'if [ -n "$FASTDDS_CONFIG_PATH" ] && [ -f "$FASTDDS_CONFIG_PATH" ]; then', + ' args+=(-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro" -e "FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e "FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e ROS_LOCALHOST_ONLY=0)', + 'fi', 'if [ -d /dev/dri ]; then args+=(--device /dev/dri); fi', 'if [ -n "${DISPLAY:-}" ] && [ -d /tmp/.X11-unix ]; then', ' args+=(-e "DISPLAY=$DISPLAY" -e QT_X11_NO_MITSHM=1 -v /tmp/.X11-unix:/tmp/.X11-unix:rw)', @@ -3301,6 +3886,7 @@ async function startWorkspaceModule( gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, + rosNetwork?: RosNetworkConfig | null, ): Promise<{ ok: boolean; message: string }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') @@ -3317,11 +3903,12 @@ async function startWorkspaceModule( await prepareRuntimeImages(runtimeImages) if (isNav2ModuleFolder(targetFolderName)) { await ensureNav2RuntimeAssets() + const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId) const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const scriptPath = resolveNav2DockerRunScript() const runtimeImage = await resolveCustomModuleRuntimeImage( moduleRoot, - resolveNav2RuntimeImage(runtimeImages, targetFolderName), + resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), ) await runCommand( 'bash', @@ -3332,10 +3919,11 @@ async function startWorkspaceModule( targetFolderName, runtimeImage, String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0', + gpuEnabled ? 'true' : 'false', ...splitRosParams(rosParams) ], undefined, - undefined, + fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : undefined, 30000 ) return { ok: true, message: '실행 요청을 보냈습니다.' } @@ -3368,7 +3956,7 @@ async function startWorkspaceModule( ], studioRuntimeRoot, { - SHARD_DIR: resolvedWorkspaceRoot + SHARD_DIR: resolvedWorkspaceRoot, }, 30000 ) @@ -3383,6 +3971,7 @@ async function buildWorkspaceModule( _gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, + rosParamsOverride?: string | null, ): Promise<{ ok: boolean; message: string }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') @@ -3397,9 +3986,10 @@ async function buildWorkspaceModule( await prepareRuntimeImages(runtimeImages) if (isNav2ModuleFolder(targetFolderName)) { await ensureNav2RuntimeAssets() + const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const runtimeImage = await resolveCustomModuleRuntimeImage( moduleRoot, - resolveNav2RuntimeImage(runtimeImages, targetFolderName), + resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), ) await runCommand( 'bash', @@ -3443,6 +4033,7 @@ async function startRemoteWorkspaceModule( gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, + rosNetwork?: RosNetworkConfig | null, ): Promise<{ ok: boolean; message: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName @@ -3481,6 +4072,7 @@ async function startRemoteWorkspaceModule( const runtimeRoot = nav2Module ? await ensureRemoteNav2RuntimeAssets(target) : await ensureRemoteStudioRuntimeAssets(target) + const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId) await runSsh( target, [ @@ -3565,8 +4157,11 @@ async function startRemoteWorkspaceModule( `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `PARAMS=${quoteShellSingle(remoteRosParams)}`, + `FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`, + ...expandRemotePathVar('FASTDDS_CONFIG_PATH'), 'read -r -a EXTRA_ARGS <<< "$PARAMS"', - `bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" ${quoteShellSingle(dockerInfo.containerKey)} "$ROOT" ${quoteShellSingle(targetFolderName)} ${quoteShellSingle(runtimeImage!)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')} "\${EXTRA_ARGS[@]}"` + 'export FASTDDS_CONFIG_PATH', + `bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" ${quoteShellSingle(dockerInfo.containerKey)} "$ROOT" ${quoteShellSingle(targetFolderName)} ${quoteShellSingle(runtimeImage!)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')} ${gpuEnabled ? 'true' : 'false'} "\${EXTRA_ARGS[@]}"` ].join('\n') : [ 'set -e', @@ -3786,9 +4381,10 @@ async function restartWorkspaceModule( gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, + rosNetwork?: RosNetworkConfig | null, ): Promise<{ ok: boolean; message: string }> { await stopWorkspaceModule(moduleName, workspaceRoot, folderName) - return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages) + return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages, rosNetwork) } async function restartRemoteWorkspaceModule( @@ -3799,9 +4395,10 @@ async function restartRemoteWorkspaceModule( gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, + rosNetwork?: RosNetworkConfig | null, ): Promise<{ ok: boolean; message: string }> { await stopRemoteWorkspaceModule(target, moduleName, folderName) - return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages) + return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages, rosNetwork) } async function readWorkspaceModuleLogs( @@ -4496,12 +5093,12 @@ function registerLocalWorkspaceHandlers(): void { return createCustomWorkspaceModule(workspaceRoot, input) }) ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) - ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) - ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages)) + ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) + ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams)) ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers()) - ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) + ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams)) ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose)) @@ -4524,24 +5121,42 @@ function registerLocalWorkspaceHandlers(): void { await writeFile(targetPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') return normalized }) + ipcMain.handle('local-workspace:maps:read-artifacts', (_event, workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactPath[]) => ( + readWorkspaceSavedMapArtifacts(workspaceRoot, artifacts) + )) + ipcMain.handle('local-workspace:maps:sync', (_event, workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactDownload[]) => ( + syncWorkspaceSavedMapArtifacts(workspaceRoot, artifacts) + )) ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules)) + ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ( + readRemoteSavedMapArtifacts(target, artifacts) + )) + ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ( + syncRemoteSavedMapArtifacts(target, artifacts) + )) ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('remote-workspace:modules:create-custom', (_event, target: RemoteWorkspaceTarget, input?: CustomModuleScaffoldInput | null) => { if (!input) throw new Error('커스텀 모듈 정보가 없습니다.') return createRemoteCustomWorkspaceModule(target, input) }) - ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) + ipcMain.handle('remote-workspace:modules:transfer', (_event, target: RemoteWorkspaceTarget, sourceWorkspaceRoot?: string | null, moduleName?: string | null, folderName?: string | null) => { + if (!moduleName?.trim()) throw new Error('전송할 모듈명이 없습니다.') + return transferLocalWorkspaceModuleToRemote(target, sourceWorkspaceRoot, moduleName, folderName) + }) + ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName)) ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules)) ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => resetRemoteWorkspaceSession(target, modules)) - ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) + ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName)) ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target)) - ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null) => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile)) + ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork)) ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target)) ipcMain.handle('remote-workspace:git:status', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null) => readRemoteWorkspaceGitStatus(target, moduleName, folderName, selectedFilePath)) - ipcMain.handle('remote-workspace:git:submit', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId)) + ipcMain.handle('remote-workspace:git:submit', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId, repositoryUrl)) + ipcMain.handle('remote-workspace:git:team-publish', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null) => publishRemoteWorkspaceTeamGitChanges(target, moduleName, folderName, message, authorName, authorEmail)) + ipcMain.handle('remote-workspace:git:team-pull', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => pullRemoteWorkspaceTeamGitChanges(target, moduleName, folderName)) ipcMain.handle('remote-workspace:git:restore-file', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null) => restoreRemoteWorkspaceGitFile(target, moduleName, folderName, filePath, status)) ipcMain.handle('remote-workspace:git:submissions', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => readRemoteWorkspaceGitSubmissions(target, modules)) ipcMain.handle('remote-workspace:git:commit-diff', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, commitHash?: string | null) => readRemoteWorkspaceGitCommitDiff(target, moduleName, folderName, commitHash)) @@ -4579,10 +5194,12 @@ function registerLocalWorkspaceHandlers(): void { return { ok: true } }) ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus()) - ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile)) + ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork)) ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile)) ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath)) - ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId)) + ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl)) + ipcMain.handle('local-workspace:git:team-publish', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null) => publishWorkspaceTeamGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail)) + ipcMain.handle('local-workspace:git:team-pull', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => pullWorkspaceTeamGitChanges(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:git:restore-file', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null) => restoreWorkspaceGitFile(moduleName, workspaceRoot, folderName, filePath, status)) ipcMain.handle('local-workspace:git:submissions', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => readWorkspaceGitSubmissions(modules, workspaceRoot)) ipcMain.handle('local-workspace:git:commit-diff', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, commitHash?: string | null) => readWorkspaceGitCommitDiff(moduleName, workspaceRoot, folderName, commitHash)) @@ -4704,25 +5321,28 @@ app.whenReady().then(() => { setImmediate(() => target.webContents.devToolsWebContents?.focus()) return { opened: true } }) + ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics()) ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close()) ipcMain.on('app-quit', () => app.quit()) ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input)) ipcMain.handle('modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) - ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) - ipcMain.handle('modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages)) + ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) + ipcMain.handle('modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams)) ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers()) - ipcMain.handle('modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) + ipcMain.handle('modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams)) ipcMain.handle('visualization:status', () => readVisualizationBridgeStatus()) - ipcMain.handle('visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile)) + ipcMain.handle('visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork)) ipcMain.handle('visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile)) ipcMain.handle('git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath)) - ipcMain.handle('git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId)) + ipcMain.handle('git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl)) + ipcMain.handle('git:team-publish', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null) => publishWorkspaceTeamGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail)) + ipcMain.handle('git:team-pull', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => pullWorkspaceTeamGitChanges(moduleName, workspaceRoot, folderName)) ipcMain.handle('git:submissions', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => readWorkspaceGitSubmissions(modules, workspaceRoot)) ipcMain.handle('git:commit-diff', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, commitHash?: string | null) => readWorkspaceGitCommitDiff(moduleName, workspaceRoot, folderName, commitHash)) ipcMain.handle('module-files:read', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => collectModuleFiles(moduleName, workspaceRoot, folderName)) diff --git a/src/preload/index.ts b/src/preload/index.ts index e99e0ce..a5f4017 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -21,6 +21,7 @@ type ModulePrebuiltArtifact = { fileName?: string | null checksumSha256?: string | null extractPath?: string | null + architecture?: string | null required?: boolean | null } @@ -37,6 +38,24 @@ type ModuleInstallOptions = { runtimeImages?: RuntimeImageArtifact[] | null } +type RosNetworkConfig = { + enabled?: boolean | null + peer?: RemoteWorkspaceTarget | null +} + +type SavedMapArtifactName = 'yaml' | 'pgm' | 'database' + +type SavedMapArtifactPath = { + name: SavedMapArtifactName + path: string +} + +type SavedMapArtifactDownload = { + name: SavedMapArtifactName + fileName: string + url: string +} + const decodeArgValue = (prefix: string): string | null => { const rawValue = process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) if (!rawValue) return null @@ -94,6 +113,9 @@ contextBridge.exposeInMainWorld('titlebar', { openDevTools: () => ipcRenderer.invoke('titlebar-open-devtools'), close: () => ipcRenderer.send('titlebar-close') }) +contextBridge.exposeInMainWorld('appDiagnostics', { + read: () => ipcRenderer.invoke('app-diagnostics:read') +}) contextBridge.exposeInMainWorld('appLifecycle', { quit: () => ipcRenderer.send('app-quit') }) @@ -113,18 +135,20 @@ contextBridge.exposeInMainWorld('modules', { list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('modules:list', workspaceRoot, modules), install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options), delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:delete', moduleName, workspaceRoot, folderName), - start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), - build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages), + start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork), + build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => ipcRenderer.invoke('modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams), stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:stop', moduleName, workspaceRoot, folderName), stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot), stopRunning: () => ipcRenderer.invoke('modules:stop-running'), - restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), + restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork), logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:logs', moduleName, workspaceRoot, folderName), updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams) }) contextBridge.exposeInMainWorld('git', { status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('git:status', moduleName, workspaceRoot, folderName, selectedFilePath), - submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId), + submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl), + teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('git:team-publish', moduleName, workspaceRoot, folderName, message, authorName, authorEmail), + teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('git:team-pull', moduleName, workspaceRoot, folderName), submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('git:submissions', modules, workspaceRoot), commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('git:commit-diff', moduleName, workspaceRoot, folderName, commitHash) }) @@ -154,7 +178,7 @@ contextBridge.exposeInMainWorld('terminal', { }) contextBridge.exposeInMainWorld('visualization', { status: () => ipcRenderer.invoke('visualization:status'), - start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile), + start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork), stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile) }) contextBridge.exposeInMainWorld('localWorkspace', { @@ -172,12 +196,12 @@ contextBridge.exposeInMainWorld('localWorkspace', { install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options), createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input), delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName), - start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), - build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages), + start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork), + build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams), stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:stop', moduleName, workspaceRoot, folderName), stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:modules:stop-many', modules, workspaceRoot), stopRunning: () => ipcRenderer.invoke('local-workspace:modules:stop-running'), - restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), + restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork), logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName), updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams) }, @@ -190,14 +214,20 @@ contextBridge.exposeInMainWorld('localWorkspace', { read: (workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:world-layout:read', workspaceRoot), write: (workspaceRoot: string | undefined, layout: unknown) => ipcRenderer.invoke('local-workspace:world-layout:write', workspaceRoot, layout) }, + maps: { + readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('local-workspace:maps:read-artifacts', workspaceRoot, artifacts), + sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('local-workspace:maps:sync', workspaceRoot, artifacts) + }, visualization: { status: () => ipcRenderer.invoke('local-workspace:visualization:status'), - start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile), + start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork), stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile) }, git: { status: (moduleName: string, workspaceRoot?: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('local-workspace:git:status', moduleName, workspaceRoot, folderName, selectedFilePath), - submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('local-workspace:git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId), + submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('local-workspace:git:submit', moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl), + teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('local-workspace:git:team-publish', moduleName, workspaceRoot, folderName, message, authorName, authorEmail), + teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:git:team-pull', moduleName, workspaceRoot, folderName), restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('local-workspace:git:restore-file', moduleName, workspaceRoot, folderName, filePath, status), submissions: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:git:submissions', modules, workspaceRoot), commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash) @@ -234,26 +264,33 @@ contextBridge.exposeInMainWorld('remoteWorkspace', { rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName), delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName) }, + maps: { + readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts), + sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts) + }, modules: { list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules), install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options), createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input), - start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), + transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:transfer', target, sourceWorkspaceRoot, moduleName, folderName), + start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork), build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages), stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName), stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules), resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules), - restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), + restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork), logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName) }, visualization: { status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target), - start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile), + start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile, rosNetwork), stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target) }, git: { status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('remote-workspace:git:status', target, moduleName, folderName, selectedFilePath), - submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId), + submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId, repositoryUrl), + teamPublish: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ipcRenderer.invoke('remote-workspace:git:team-publish', target, moduleName, folderName, message, authorName, authorEmail), + teamPull: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:git:team-pull', target, moduleName, folderName), restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: string) => ipcRenderer.invoke('remote-workspace:git:restore-file', target, moduleName, folderName, filePath, status), submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:git:submissions', target, modules), commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash) diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index 6608613..fd81429 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -24,7 +24,7 @@ #miniMapToggle { position:absolute; right:16px; - bottom:236px; + bottom:240px; z-index:11; min-height:28px; padding:0 10px; @@ -76,6 +76,65 @@ backdrop-filter:blur(8px); box-shadow:0 10px 28px rgba(0,0,0,.16); } + #rosTopicStatusWrap { + position:absolute; + left:16px; + bottom:54px; + z-index:10; + width:178px; + } + #rosTopicStatus { + overflow:hidden; + border:1px solid rgba(255,255,255,.24); + background:rgba(17,24,39,.48); + color:rgba(255,255,255,.88); + font:11px sans-serif; + backdrop-filter:blur(8px); + box-shadow:0 10px 28px rgba(0,0,0,.16); + } + #rosTopicStatusToggle { + position:absolute; + left:0; + top:-28px; + display:grid; + width:30px; + height:28px; + place-items:center; + padding:0; + border:1px solid rgba(255,255,255,.22); + background:rgba(17,24,39,.42); + color:rgba(255,255,255,.9); + font:11px sans-serif; + font-weight:800; + cursor:pointer; + backdrop-filter:blur(8px); + box-shadow:0 10px 28px rgba(0,0,0,.16); + } + #rosTopicStatusToggle:hover { color:#fff; background:rgba(255,255,255,.06); } + #rosTopicStatusWrap.is-collapsed #rosTopicStatus { display:none; } + #rosTopicStatusChevron { font-size:13px; line-height:1; } + #rosTopicStatusWrap.is-collapsed #rosTopicStatusChevron { transform:rotate(180deg); } + #rosTopicStatusTitle { + min-height:30px; + display:flex; + align-items:center; + padding:0 9px; + border-bottom:1px solid rgba(255,255,255,.14); + font-weight:800; + } + #rosTopicStatusList { padding:4px 0; } + .ros-topic-row { + display:flex; + align-items:center; + justify-content:space-between; + gap:8px; + min-height:23px; + padding:0 9px; + } + .ros-topic-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .ros-topic-state { flex:0 0 auto; font-size:12px; font-weight:900; } + .ros-topic-state.is-ok { color:#5eead4; } + .ros-topic-state.is-error { color:#fca5a5; } #responseWaitOverlay { position:fixed; inset:0; @@ -247,17 +306,19 @@ background:rgba(255,255,255,.1); color:#fff; } - .editor-mode #topRightControls, + .editor-mode #topRightControls, .editor-mode #miniMap, .editor-mode #miniMapToggle, - .editor-mode #keyGuide { + .editor-mode #keyGuide, + .editor-mode #rosTopicStatusWrap { display:none; } #splatToggle { display:none; } - .nav2-slam #goalToggle, .nav2-slam #poseToggle, .nav2-slam #sensorToggle { display:none; } + .nav2-slam #goalToggle, .nav2-slam #poseToggle, .nav2-slam #sensorToggle, .nav2-slam #initToggle { display:none; } .nav2-navigation #downloadWrap { display:none; } .nav2-navigation #topLeftControls { display:block; } .nav2-navigation #keyGuide { display:none; } + .nav2-navigation #rosTopicStatusWrap { bottom:16px; } .nav2-navigation.real-runtime #splatToggle { display:inline-flex; } .real-runtime #topLeftControls { display:none; } .real-runtime #keyGuide { display:none; } @@ -288,6 +349,15 @@
i 전진 · , 후진 · j/l 회전 · k 정지
+
+ + +
@@ -315,9 +385,17 @@ import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d'; const viewerMapKey = new URLSearchParams(window.location.search).get('mapKey'); const params = new URLSearchParams(location.search); const REAL_RUNTIME = params.get('runtime') === 'real'; +const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase(); +// Keep the prior real-platform default when an older LMS URL has no platform. +const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME); const SHOW_POINT_CLOUD = params.get('pointCloud') === '1'; const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom'; const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world'; +const MKMINI_WORLD_SPACING_SCALE = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world' ? 1.6 : 1; +const MKMINI_RENDER_LENGTH = 1.05; +const DEFAULT_SIMULATION_SPAWN = USE_MKMINI_MODEL + ? { x: -1.4, y: -0.5 } + : { x: -2.0, y: -0.5 }; const MODEL_ROOT = `./turtlebot3_world_model/${activeWorldModel}`; const MODEL_SDF = `${MODEL_ROOT}/model.sdf`; const miniMap = document.getElementById('miniMap'); @@ -333,6 +411,10 @@ const goalToggleButton = document.getElementById('goalToggle'); const obstacleToggleButton = document.getElementById('obstacleToggle'); const initToggleButton = document.getElementById('initToggle'); const cameraPresetSelect = document.getElementById('cameraPresetSelect'); +const rosTopicStatusWrap = document.getElementById('rosTopicStatusWrap'); +const rosTopicStatus = document.getElementById('rosTopicStatus'); +const rosTopicStatusToggle = document.getElementById('rosTopicStatusToggle'); +const rosTopicStatusList = document.getElementById('rosTopicStatusList'); const EDITOR_MODE = params.get('editor') === '1'; const NAV2_VIEW_MODE = params.get('mode') === 'navigation' ? 'navigation' : 'slam'; const SHOW_REMOTE_NAVIGATION_ROOM = REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation'; @@ -347,6 +429,44 @@ const cameraPresetSelect = document.getElementById('cameraPresetSelect'); if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden'); let visualizationResponseReceived = EDITOR_MODE; +const ROS_TOPIC_STATUS_KEYS = NAV2_VIEW_MODE === 'navigation' + ? ['scan', 'odom', 'map', 'amcl_pose', 'plan', 'cmd_vel', 'navigate_to_pose', ...(SHOW_POINT_CLOUD ? ['point_cloud'] : [])] + : ['scan', 'odom', 'map']; +const ROS_TOPIC_STATUS_FALLBACK_NAMES = { + scan: '/scan', + odom: '/odom', + map: '/map', + amcl_pose: '/amcl_pose', + plan: '/plan', + cmd_vel: '/cmd_vel', + navigate_to_pose: '/navigate_to_pose', + point_cloud: '/cloud_map', +}; + +function updateRosTopicStatus(topicStatus = {}) { + if (!rosTopicStatusList) return; + rosTopicStatusList.replaceChildren(...ROS_TOPIC_STATUS_KEYS.map((key) => { + const status = topicStatus?.[key] ?? {}; + const row = document.createElement('div'); + row.className = 'ros-topic-row'; + const name = document.createElement('span'); + name.className = 'ros-topic-name'; + name.textContent = status.name || ROS_TOPIC_STATUS_FALLBACK_NAMES[key] || key; + const indicator = document.createElement('span'); + const available = status.available === true; + indicator.className = `ros-topic-state ${available ? 'is-ok' : 'is-error'}`; + indicator.textContent = available ? 'O' : 'X'; + row.append(name, indicator); + return row; + })); +} + +rosTopicStatusToggle?.addEventListener('click', () => { + const collapsed = rosTopicStatusWrap?.classList.toggle('is-collapsed') ?? false; + rosTopicStatusToggle.setAttribute('aria-expanded', String(!collapsed)); + rosTopicStatusToggle.setAttribute('aria-label', collapsed ? 'ROS 상태 펼치기' : 'ROS 상태 접기'); +}); +updateRosTopicStatus(); function markVisualizationResponseReceived() { if (visualizationResponseReceived) return; @@ -421,7 +541,7 @@ let remoteNavigationSplatTransform = null; let remoteNavigationSplatEnabled = false; let remoteNavigationSplatLoading = null; // 화면의 빨간 X축과 평행하게 오른쪽으로 미세 보정합니다. -const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.7322845855746674, 1.3107954323633903, -0.8021416967574936); +const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.6822845855746674, 1.3107954323633903, -0.8021416967574936); const remoteNavigationSplatBaseQuaternion = new THREE.Quaternion(-0.10999094822602469, 0.6984998148234114, 0.6984998148234114, 0.10999094822602469); const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.672624762730334, 3.5309628207501587); const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null; @@ -651,6 +771,13 @@ let obstaclePreviewTemplate = null; const navigationRaycaster = new THREE.Raycaster(); const navigationGroundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); +// A saved map, the remote room model, and AMCL's pose all use `map`. +// Keep the real-platform navigation scene in that frame. Local Gazebo still +// needs odom coordinates for its pose service, so its conversion remains. +function navigationSceneUsesMapFrame() { + return REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation'; +} + function syncGoalToggleAvailability() { const disabled = NAV2_VIEW_MODE === 'navigation' && !navigationInitialPoseSet; goalToggleButton.disabled = disabled; @@ -694,6 +821,17 @@ function applySdfPose(obj, pose) { obj.position.set(pose.x, pose.y, pose.z); obj.rotation.set(pose.roll, pose.pitch, pose.yaw, 'XYZ'); } +function applyWorldSdfPose(obj, pose) { + applySdfPose(obj, { + ...pose, + x: pose.x * MKMINI_WORLD_SPACING_SCALE, + y: pose.y * MKMINI_WORLD_SPACING_SCALE, + }); +} +function isMkMiniWorldPillar(visual) { + return MKMINI_WORLD_SPACING_SCALE > 1 + && /^(one|two|three)_/.test(visual.getAttribute('name') || ''); +} function getFirst(el, tag) { return el?.getElementsByTagName(tag)?.[0] || null; } function directChild(el, tag) { return Array.from(el.children || []).find(c => c.tagName === tag) || null; } function getDirectText(el, tag, fallback = '') { @@ -754,6 +892,9 @@ async function addMesh(parent, visual, geometry) { if (!uri) return; const url = gazeboUriToPath(uri); const scale = parseScale(getDirectText(meshEl, 'scale', '1 1 1')); + if (MKMINI_WORLD_SPACING_SCALE > 1 && uri.endsWith('/wall.dae')) { + scale.multiplyScalar(MKMINI_WORLD_SPACING_SCALE); + } const material = materialFromVisual(visual); if (/\.stl$/i.test(url)) { @@ -801,7 +942,8 @@ function addBox(parent, visual, geometry) { function addCylinder(parent, visual, geometry) { const cyl = directChild(geometry, 'cylinder'); const radius = Number(getDirectText(cyl, 'radius', '0.1')) || .1; - const length = Number(getDirectText(cyl, 'length', '1')) || 1; + const sourceLength = Number(getDirectText(cyl, 'length', '1')) || 1; + const length = isMkMiniWorldPillar(visual) && sourceLength === .5 ? .8 : sourceLength; const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 32), materialFromVisual(visual)); // Three cylinder 기본 축은 Y, SDF cylinder 기본 축은 Z입니다. mesh.rotation.x = Math.PI / 2; @@ -827,7 +969,7 @@ async function buildFromSdf() { const model = doc.querySelector('model'); const modelGroup = new THREE.Group(); modelGroup.name = model?.getAttribute('name') || 'turtlebot3_world'; - applySdfPose(modelGroup, parsePose(getDirectText(model, 'pose', '0 0 0 0 0 0'))); + applyWorldSdfPose(modelGroup, parsePose(getDirectText(model, 'pose', '0 0 0 0 0 0'))); sdfRoot.add(modelGroup); let loaded = 0; @@ -835,19 +977,20 @@ async function buildFromSdf() { const nestedModelGroup = new THREE.Group(); let directGeometryCount = 0; nestedModelGroup.name = sdfModel.getAttribute('name') || 'model'; - applySdfPose(nestedModelGroup, parsePose(getDirectText(sdfModel, 'pose', '0 0 0 0 0 0'))); + applyWorldSdfPose(nestedModelGroup, parsePose(getDirectText(sdfModel, 'pose', '0 0 0 0 0 0'))); parent.add(nestedModelGroup); for (const link of Array.from(sdfModel.querySelectorAll(':scope > link'))) { const linkGroup = new THREE.Group(); linkGroup.name = link.getAttribute('name') || 'link'; - applySdfPose(linkGroup, parsePose(getDirectText(link, 'pose', '0 0 0 0 0 0'))); + applyWorldSdfPose(linkGroup, parsePose(getDirectText(link, 'pose', '0 0 0 0 0 0'))); nestedModelGroup.add(linkGroup); for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) { const visualGroup = new THREE.Group(); visualGroup.name = visual.getAttribute('name') || 'visual'; - applySdfPose(visualGroup, parsePose(getDirectText(visual, 'pose', '0 0 0 0 0 0'))); + applyWorldSdfPose(visualGroup, parsePose(getDirectText(visual, 'pose', '0 0 0 0 0 0'))); + if (isMkMiniWorldPillar(visual)) visualGroup.position.z += 0.15; linkGroup.add(visualGroup); const geometry = directChild(visual, 'geometry'); @@ -983,7 +1126,8 @@ async function loadMkMiniModel(sdfPose = { x:0, y:0, z:0, roll:0, pitch:0, yaw:0 const initialBounds = new THREE.Box3().setFromObject(model); const initialSize = initialBounds.getSize(new THREE.Vector3()); const largestDimension = Math.max(initialSize.x, initialSize.y, initialSize.z, 0.001); - model.scale.setScalar(0.66 / largestDimension); + // Match the Gazebo MKMini collision body's 1.05 m longest dimension. + model.scale.setScalar(MKMINI_RENDER_LENGTH / largestDimension); model.updateMatrixWorld(true); const normalizedBounds = new THREE.Box3().setFromObject(model); @@ -1083,14 +1227,15 @@ async function setRemoteNavigationSplatVisible(visible) { } const LASER_HOLE_R = 0.14; -const LASER_OFFSET_X = REAL_RUNTIME ? 0.1 : -0.032; +const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.1 : -0.032; const LASER_OFFSET_Y = 0; // Calibrated to the MKMini GLB at the current display scale. -const LASER_Z = REAL_RUNTIME ? 0.5 : 0.181; +const LASER_Z = USE_MKMINI_MODEL ? 0.5 : 0.181; // The MKMini ROS frame reports os_lidar at Z=0.6 m while the bundled GLB // places the visible LiDAR head at approximately Z=0.5 m. Keep ROS/map // coordinates untouched and correct only the rendered scan origin. const MKMINI_LASER_RENDER_Z_OFFSET = 0.1; +const MKMINI_LASER_RENDER_FORWARD_OFFSET = 0.10; function setRobotPose(pose) { if (!pose) return; @@ -1234,7 +1379,7 @@ function setNavigationSensorVisible(visible) { function drawLaserScan(scan, scanPose = ( NAV2_VIEW_MODE === 'navigation' - ? latestSlamState?.scan_pose + ? (navigationSceneUsesMapFrame() ? latestSlamState?.scan_pose_map : latestSlamState?.scan_pose) : latestSlamState?.scan_pose_map ) || null) { if (NAV2_VIEW_MODE === 'navigation' && (!navigationInitialPoseSet || !navigationSensorVisible)) { @@ -1245,8 +1390,13 @@ function drawLaserScan(scan, scanPose = ( clearLaserScan(); const sourcePose = scanPose || currentRobotPose; - const renderPose = REAL_RUNTIME && scanPose - ? { ...sourcePose, z: (sourcePose.z || 0) - MKMINI_LASER_RENDER_Z_OFFSET } + const renderPose = USE_MKMINI_MODEL && scanPose + ? { + ...sourcePose, + x: sourcePose.x + Math.cos(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_FORWARD_OFFSET, + y: sourcePose.y + Math.sin(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_FORWARD_OFFSET, + z: (sourcePose.z || 0) - MKMINI_LASER_RENDER_Z_OFFSET, + } : sourcePose; const origin = scanPose ? new THREE.Vector3(renderPose.x, renderPose.y, renderPose.z || 0) @@ -1268,31 +1418,24 @@ function drawLaserScan(scan, scanPose = ( const start = origin.clone().addScaledVector(dir, startD); const point = origin.clone().addScaledVector(dir, d); endpoints.push({ start, point, hit: valid }); - linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z); + // A range-max value represents open space, not an actual reflected beam. + if (valid) linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z); } const fillPoints = []; - const openFillPoints = []; for (let i = 0; i < endpoints.length; i++) { const curr = endpoints[i]; const next = endpoints[(i + 1) % endpoints.length]; - const targetPoints = curr.hit && next.hit ? fillPoints : openFillPoints; - targetPoints.push(curr.start.x, curr.start.y, curr.start.z); - targetPoints.push(curr.point.x, curr.point.y, curr.point.z); - targetPoints.push(next.point.x, next.point.y, next.point.z); - targetPoints.push(curr.start.x, curr.start.y, curr.start.z); - targetPoints.push(next.point.x, next.point.y, next.point.z); - targetPoints.push(next.start.x, next.start.y, next.start.z); + if (!curr.hit || !next.hit) continue; + fillPoints.push(curr.start.x, curr.start.y, curr.start.z); + fillPoints.push(curr.point.x, curr.point.y, curr.point.z); + fillPoints.push(next.point.x, next.point.y, next.point.z); + fillPoints.push(curr.start.x, curr.start.y, curr.start.z); + fillPoints.push(next.point.x, next.point.y, next.point.z); + fillPoints.push(next.start.x, next.start.y, next.start.z); } const grp = new THREE.Group(); - const openFillMat = new THREE.MeshBasicMaterial({ - color: 0x3390ff, - transparent: true, - opacity: 0.18, - side: THREE.DoubleSide, - depthWrite: false, - }); const fillMat = new THREE.MeshBasicMaterial({ color: 0x005cff, transparent: true, @@ -1302,11 +1445,6 @@ function drawLaserScan(scan, scanPose = ( }); const lineMat = new THREE.LineBasicMaterial({ color: 0x3366ff, transparent: true, opacity: 0.5 }); - if (openFillPoints.length) { - const geo = new THREE.BufferGeometry(); - geo.setAttribute('position', new THREE.Float32BufferAttribute(openFillPoints, 3)); - grp.add(new THREE.Mesh(geo, openFillMat)); - } if (fillPoints.length) { const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(fillPoints, 3)); @@ -1318,7 +1456,7 @@ function drawLaserScan(scan, scanPose = ( const frontIndex = Math.max(0, Math.min(scan.ranges.length - 1, Math.round((0 - angleMin) / angleInc))); const front = endpoints[frontIndex]; - if (front) { + if (front?.hit) { const delta = new THREE.Vector3( front.point.x - front.start.x, front.point.y - front.start.y, @@ -1549,6 +1687,12 @@ function updateMiniMapMetadata(map) { function updateNavigationMapTransform() { if (NAV2_VIEW_MODE !== 'navigation') return; + if (navigationSceneUsesMapFrame()) { + navigationMapRoot.position.set(0, 0, 0); + navigationMapRoot.rotation.set(0, 0, 0); + updateRemoteNavigationRoomTransform(); + return; + } const transform = navigationMapToOdom || { x:0, y:0, yaw:0 }; const yaw = transform.yaw || 0; const c = Math.cos(yaw); @@ -1567,8 +1711,19 @@ function updateNavigationMapTransform() { function updateRemoteNavigationRoomTransform() { if (!remoteNavigationSplatEnabled || !remoteNavigationSplatScene || NAV2_VIEW_MODE !== 'navigation') return; + if (navigationSceneUsesMapFrame()) { + if (remoteNavigationSplatTransform?.frame === 'map') return; + remoteNavigationSplatScene.position.copy(remoteNavigationSplatBasePosition); + remoteNavigationSplatScene.quaternion.copy(remoteNavigationSplatBaseQuaternion); + remoteNavigationSplatScene.scale.copy(remoteNavigationSplatBaseScale); + remoteNavigationSplatViewer.splatMesh.updateTransforms(); + remoteNavigationSplatTransform = { frame: 'map' }; + return; + } + const transform = navigationMapToOdom || { x:0, y:0, yaw:0 }; if (remoteNavigationSplatTransform + && remoteNavigationSplatTransform.frame === 'odom' && Math.abs(remoteNavigationSplatTransform.x - (transform.x || 0)) < 0.03 && Math.abs(remoteNavigationSplatTransform.y - (transform.y || 0)) < 0.03 && Math.abs(remoteNavigationSplatTransform.yaw - (transform.yaw || 0)) < 0.01) { @@ -1600,6 +1755,7 @@ function updateRemoteNavigationRoomTransform() { ); remoteNavigationSplatViewer.splatMesh.updateTransforms(); remoteNavigationSplatTransform = { + frame: 'odom', x: transform.x || 0, y: transform.y || 0, yaw, @@ -1656,8 +1812,10 @@ function renderNavigationOccupancyMap() { updateNavigationMapTransform(); if (!navigationMapFocused) { - const odomCenter = mapToOdom({ x:centerX, y:centerY, yaw:originYaw }); - const center = sdfPointToWorld(odomCenter.x, odomCenter.y, 0); + const sceneCenter = navigationSceneUsesMapFrame() + ? { x:centerX, y:centerY } + : mapToOdom({ x:centerX, y:centerY, yaw:originYaw }); + const center = sdfPointToWorld(sceneCenter.x, sceneCenter.y, 0); const distance = Math.max(width, height) * 1.1; controls.target.copy(center); camera.position.set(center.x + distance * .62, center.y + distance * .86, center.z + distance * .62); @@ -2040,7 +2198,7 @@ function placeNavigationGoalMarker(x, y, yaw = 0) { function updateNavigationGoalMarker() { if (!navigationGoalMarker || !navigationGoalPose) return; - const markerPose = NAV2_VIEW_MODE === 'navigation' + const markerPose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(navigationGoalPose) : navigationGoalPose; navigationGoalMarker.position.set(markerPose.x, markerPose.y, 0.05); @@ -2055,9 +2213,9 @@ function setNavigationGoalPreview(pose) { ); sdfRoot.add(navigationGoalPreview); } - const odomPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; - navigationGoalPreview.position.set(odomPose.x, odomPose.y, 0.06); - navigationGoalPreview.rotation.z = odomPose.yaw || 0; + const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose; + navigationGoalPreview.position.set(scenePose.x, scenePose.y, 0.06); + navigationGoalPreview.rotation.z = scenePose.yaw || 0; navigationGoalPreview.visible = true; } @@ -2098,8 +2256,8 @@ function setObstaclePreview(pose) { ); sdfRoot.add(obstaclePreview); } - const odomPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; - obstaclePreview.position.set(odomPose.x, odomPose.y, obstaclePreviewTemplate.height / 2); + const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose; + obstaclePreview.position.set(scenePose.x, scenePose.y, obstaclePreviewTemplate.height / 2); obstaclePreview.rotation.z = obstaclePreviewTemplate.yaw; obstaclePreview.visible = true; } @@ -2122,8 +2280,8 @@ function updateNavigationPlan(plan) { } if (poses.length < 2) return; const points = poses.map((pose) => { - const odomPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; - return new THREE.Vector3(odomPose.x, odomPose.y, 0.07); + const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose; + return new THREE.Vector3(scenePose.x, scenePose.y, 0.07); }); navigationPlanLine = new THREE.Line( new THREE.BufferGeometry().setFromPoints(points), @@ -2150,19 +2308,29 @@ function pickNavigationGoal(event) { const worldPoint = new THREE.Vector3(); if (!navigationRaycaster.ray.intersectPlane(navigationGroundPlane, worldPoint)) return null; const sdfPoint = worldPointToSdf(worldPoint); - const mapPoint = NAV2_VIEW_MODE === 'navigation' + const mapPoint = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? odomToMap({ x: sdfPoint.x, y: sdfPoint.y, yaw: currentRobotPose.yaw || 0 }) : sdfPoint; + const robotMapPose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() + ? odomToMap(currentRobotPose) + : currentRobotPose; + const headingToGoal = Math.atan2( + mapPoint.y - (robotMapPose?.y || 0), + mapPoint.x - (robotMapPose?.x || 0), + ); return { x: mapPoint.x, y: mapPoint.y, - yaw: mapPoint.yaw || 0, + // A Dubins-based Nav2 planner must know which way the vehicle should face + // at the destination. The clicked ground point has no yaw, so using zero + // forced every goal to face east and made otherwise valid paths fail. + yaw: Number.isFinite(headingToGoal) ? headingToGoal : (mapPoint.yaw || 0), }; } function showInitialPosePreview(pose) { if (!posePreviewModel) return; - const scenePose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; + const scenePose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose; posePreviewModel.position.set(scenePose.x, scenePose.y, 0.01); posePreviewModel.rotation.set(0, 0, scenePose.yaw || 0); posePreviewModel.visible = true; @@ -2235,7 +2403,7 @@ async function sendInitialPose(pose) { hideInitialPosePreview(); return; } - const gazeboPose = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(pose) : pose; + const gazeboPose = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(pose) : pose; const requestId = `initial-pose-${Date.now()}-${++initialPoseRequestSequence}`; await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { @@ -2397,11 +2565,11 @@ renderer.domElement.addEventListener('click', async (event) => { if (!goal) return; try { if (obstaclePlacementMode) { - const sdfPoint = NAV2_VIEW_MODE === 'navigation' ? mapToOdom(goal) : goal; + const scenePoint = NAV2_VIEW_MODE === 'navigation' && !navigationSceneUsesMapFrame() ? mapToOdom(goal) : goal; const obstacle = { id: `runtime-wall-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - x: sdfPoint.x, - y: sdfPoint.y, + x: scenePoint.x, + y: scenePoint.y, ...(obstaclePreviewTemplate ?? createObstaclePreviewTemplate()), }; window.parent.postMessage({ source:'autonomy-slam-viewer', type:'runtime-obstacle-add', obstacle }, '*'); @@ -2422,6 +2590,7 @@ renderer.domElement.addEventListener('click', async (event) => { function applySlamState(state) { latestSlamState = state; + updateRosTopicStatus(state?.topic_status); if ( navigationGoalMarker && navigationGoalId @@ -2437,11 +2606,13 @@ function applySlamState(state) { updateNavigationMapTransform(); } updateNavigationPlan(state?.plan); - // Navigation is rendered in odom so the map, robot, path and goal share - // one scene frame. SLAM keeps its map-frame pose for the live map view. + // Remote navigation is rendered in the map frame, matching the saved map, + // room model, AMCL pose, route and goal. Local Gazebo remains odom-based. const visualizationPose = SHOW_POINT_CLOUD ? (state?.pose_3d || state?.pose) - : (NAV2_VIEW_MODE === 'navigation' ? state?.pose : (state?.pose_map || state?.pose)); + : (NAV2_VIEW_MODE === 'navigation' + ? (navigationSceneUsesMapFrame() ? (state?.pose_map || state?.pose) : state?.pose) + : (state?.pose_map || state?.pose)); if (visualizationPose) setRobotPose(visualizationPose); if (state?.scan) { latestScanMetadata = state.scan; @@ -2658,7 +2829,7 @@ const worldSceneTasks = CUSTOM_WORLD || REAL_RUNTIME const remoteNavigationSceneTasks = []; Promise.all(EDITOR_MODE ? worldSceneTasks - : [...worldSceneTasks, ...remoteNavigationSceneTasks, (REAL_RUNTIME ? loadMkMiniModel : loadBurgerModel)({ x:-2.0, y:-0.5, z:0.01, roll:0, pitch:0, yaw:0 })] + : [...worldSceneTasks, ...remoteNavigationSceneTasks, (USE_MKMINI_MODEL ? loadMkMiniModel : loadBurgerModel)({ ...DEFAULT_SIMULATION_SPAWN, z:0.01, roll:0, pitch:0, yaw:0 })] ).then(() => { if (EDITOR_MODE) { window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*'); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 40fe84c..288ee14 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -43,7 +43,7 @@ import WorkspaceLogView from './features/workspace/WorkspaceLogView' import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu' import ModuleListView from './features/workspace/ModuleListView' import WorldEditorModal from './features/workspace/WorldEditorModal' -import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal' +import WorldMapSelectorModal, { WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal' import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel' import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView' import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView' @@ -65,10 +65,10 @@ import { useWorkspaceVisualization } from './features/workspace/useWorkspaceVisu import { makeModuleParameterKey, readStoredModuleParameters, serializeModuleParameters } from './features/workspace/moduleParameters' import { parseHostFromUrl, readNestedString } from './features/workspace/remoteWorkspaceTarget' import { resolveRuntimeModules } from './features/workspace/runtimeModuleSet' -import { defaultStudioSourceSettings, makePlatformWorkspaceRoot, makeWeekWorkspaceRoot, normalizeStudioSourceSettings, readStudioSourceSettings, storeStudioSourceSettings } from './features/workspace/sourceSettings' +import { defaultStudioSourceSettings, makePlatformWorkspaceRoot, makeTeamWeekWorkspaceRoot, makeWeekWorkspaceRoot, normalizeStudioSourceSettings, readStudioSourceSettings, storeStudioSourceSettings } from './features/workspace/sourceSettings' import { makeWorkspaceUiSessionKey, readStudioRuntimeSessionState, storeStudioRuntimeSessionState } from './features/workspace/studioRuntimeSession' import type { StudioRuntimeSessionState } from './features/workspace/studioRuntimeSession' -import type { ModuleRow, RuntimeImageArtifact } from './features/workspace/types' +import type { ModuleRow, RosNetworkConfig, RuntimeImageArtifact } from './features/workspace/types' import { formatControlRemainingTime, formatSessionClock } from './features/workspace/workspaceFormatters' import { buildModuleTreeItems, buildPathTreeItems, getEditorLanguage } from './features/workspace/workspaceFiles' import type { EditorFilePath } from './features/workspace/workspaceFiles' @@ -143,7 +143,6 @@ const EMPTY_WEEK: CurriculumWeek = { topicVideoUrls: [], references: [], deliverable: '', - requiresSourceSubmission: true, sourceSubmitted: false, sourceSubmittedAt: null } @@ -385,6 +384,30 @@ type ModuleWorkspaceSnapshot = { selectedTreePath: string | null } +type WorkspaceVisualizationStateSetter = (value: Value | ((current: Value) => Value)) => void + +type VisualizationResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' + +type WorkspaceVisualizationHost = { + instanceId: string + ownerPlatformId: string + dark: boolean + textStrong: string + textDim: string + iconButtonClass: string + available: boolean + running: boolean + busy: boolean + viewerUrl: string + onStart: () => Promise + onTerminate: () => Promise + onDragStart: (event: React.MouseEvent) => void + onToggleLarge: () => void + onMinimize: () => void + onClose: () => void + onResizeStart: (event: React.MouseEvent, direction: VisualizationResizeDirection) => void +} + function LocalPcWorkspaceMockup({ onClose, onDisconnectSession, @@ -393,6 +416,7 @@ function LocalPcWorkspaceMockup({ onTitlebarNotice, onRegisterStopRunningModules, onRegisterRunningModuleNames, + onRunningModuleCountChange, canReleaseControl, canExtendControl, controlEndsAt, @@ -403,6 +427,18 @@ function LocalPcWorkspaceMockup({ platformKey, platformEnvironmentProfiles, platformEnvironmentConfigOverride, + platformOptions, + skipInitialLoading, + visualizationOpen, + onVisualizationOpenChange, + visualizationMinimized, + onVisualizationMinimizedChange, + visualizationLarge, + onVisualizationLargeChange, + visualizationPosition, + onVisualizationPositionChange, + visualizationSize, + onVisualizationSizeChange, sourceSettingsDefaults, downloadResources, accessToken, @@ -412,10 +448,12 @@ function LocalPcWorkspaceMockup({ weekNumber, courseKey, curriculumWeek, + teamGroupId, activeLearningStep, allowFutureLearningSteps, onLearningStepSelect, - onSubmissionRecorded + onSubmissionRecorded, + onVisualizationHostChange }: { onClose: () => void onDisconnectSession: () => void @@ -424,6 +462,7 @@ function LocalPcWorkspaceMockup({ onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void onRegisterStopRunningModules?: (handler: (() => Promise) | null) => void onRegisterRunningModuleNames?: (handler: (() => string[]) | null) => void + onRunningModuleCountChange?: (count: number) => void canReleaseControl: boolean canExtendControl: boolean controlEndsAt?: string | null @@ -434,6 +473,18 @@ function LocalPcWorkspaceMockup({ platformKey: string platformEnvironmentProfiles?: EnvironmentProfile[] platformEnvironmentConfigOverride?: Record | null + platformOptions: PlatformOption[] + skipInitialLoading?: boolean + visualizationOpen: boolean + onVisualizationOpenChange: WorkspaceVisualizationStateSetter + visualizationMinimized: boolean + onVisualizationMinimizedChange: WorkspaceVisualizationStateSetter + visualizationLarge: boolean + onVisualizationLargeChange: WorkspaceVisualizationStateSetter + visualizationPosition: { x: number; y: number } + onVisualizationPositionChange: WorkspaceVisualizationStateSetter<{ x: number; y: number }> + visualizationSize: { width: number; height: number } + onVisualizationSizeChange: WorkspaceVisualizationStateSetter<{ width: number; height: number }> sourceSettingsDefaults?: Partial | null downloadResources: DownloadResource[] accessToken: string @@ -443,11 +494,14 @@ function LocalPcWorkspaceMockup({ weekNumber: number courseKey: string curriculumWeek: CurriculumWeek + teamGroupId?: string | null activeLearningStep: string | null allowFutureLearningSteps: boolean onLearningStepSelect?: (step: string) => void onSubmissionRecorded: () => void + onVisualizationHostChange?: (host: WorkspaceVisualizationHost) => void }): React.JSX.Element { + const visualizationHostInstanceId = useRef(`${platformId}:${Math.random().toString(36).slice(2)}`).current const [powerActionModalOpen, setPowerActionModalOpen] = useState(false) const [controlCountdownNow, setControlCountdownNow] = useState(Date.now()) const { @@ -481,22 +535,76 @@ function LocalPcWorkspaceMockup({ const [downloadingResourceIds, setDownloadingResourceIds] = useState([]) const [activeIdeActivity, setActiveIdeActivity] = useState('modules') const exampleActive = activeIdeActivity === 'examples' + const remoteWorkspace = platformKey !== 'futuredrive-local-pc' const platformSourceSettingsDefaults = normalizeStudioSourceSettings(sourceSettingsDefaults) const [sourceSettings, setSourceSettings] = useState(() => readStudioSourceSettings(platformId, platformSourceSettingsDefaults)) const [sourceSettingsDraft, setSourceSettingsDraft] = useState(() => readStudioSourceSettings(platformId, platformSourceSettingsDefaults)) + const localWorkspaceSettings = useMemo( + () => readStudioSourceSettings('futuredrive-local-pc', defaultStudioSourceSettings), + [platformId, sourceSettings.rosLinkedPlatformId, sourceSettings.rosDomainId, sourceSettings.visualizationBridgeLocation], + ) + const linkedPlatformId = remoteWorkspace + ? localWorkspaceSettings.rosLinkedPlatformId + : sourceSettings.rosLinkedPlatformId + const linkedPlatform = useMemo( + () => platformOptions.find((platform) => platform.id === linkedPlatformId && platform.platformKey !== 'futuredrive-local-pc') ?? null, + [linkedPlatformId, platformOptions], + ) + useEffect(() => { + if (remoteWorkspace || !sourceSettings.rosLinkedPlatformId) return + + // Wait until the curriculum platform list is available before clearing a stale link. + if (!platformOptions.some((platform) => platform.platformKey === 'futuredrive-local-pc')) return + if (platformOptions.some((platform) => platform.id === sourceSettings.rosLinkedPlatformId)) return + + const nextSettings = normalizeStudioSourceSettings({ + ...sourceSettings, + rosLinkedPlatformId: null, + visualizationBridgeLocation: 'local', + }) + setSourceSettings(nextSettings) + setSourceSettingsDraft((draft) => draft.rosLinkedPlatformId === sourceSettings.rosLinkedPlatformId + ? { ...draft, rosLinkedPlatformId: null, visualizationBridgeLocation: 'local' } + : draft) + storeStudioSourceSettings(platformId, nextSettings) + }, [platformId, platformOptions, remoteWorkspace, sourceSettings]) + const rosWorkspaceLinked = remoteWorkspace + ? linkedPlatformId === platformId + : Boolean(linkedPlatform) + const effectiveRosDomainId = rosWorkspaceLinked + ? localWorkspaceSettings.rosDomainId + : sourceSettings.rosDomainId + const runtimeSourceSettings = useMemo( + () => ({ ...sourceSettings, rosDomainId: effectiveRosDomainId }), + [effectiveRosDomainId, sourceSettings], + ) const [pendingSourceSettingsSave, setPendingSourceSettingsSave] = useState(null) const [selectedModule, setSelectedModule] = useState(null) const [moduleTabs, setModuleTabs] = useState([]) const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false) + const [teamGitSyncing, setTeamGitSyncing] = useState(false) const [workspaceNavFlash, setWorkspaceNavFlash] = useState(false) const [moduleWorkspaceSnapshots, setModuleWorkspaceSnapshots] = useState>({}) - const [previewOpen, setPreviewOpen] = useState(false) + const previewOpen = visualizationOpen + const setPreviewOpen = onVisualizationOpenChange + const previewMinimized = visualizationMinimized + const setPreviewMinimized = onVisualizationMinimizedChange const [worldMapSelectorOpen, setWorldMapSelectorOpen] = useState(false) const [pendingWorldMapRestart, setPendingWorldMapRestart] = useState(null) const [worldEditorOpen, setWorldEditorOpen] = useState(false) - const [previewLarge, setPreviewLarge] = useState(false) - const [previewPosition, setPreviewPosition] = useState({ x: 1040, y: 72 }) - const [previewSize, setPreviewSize] = useState({ width: 600, height: 600 }) + const previewLarge = visualizationLarge + const setPreviewLarge = onVisualizationLargeChange + const previewPosition = visualizationPosition + const setPreviewPosition = onVisualizationPositionChange + const previewSize = visualizationSize + const setPreviewSize = onVisualizationSizeChange + const closeVisualizationPanel = (): void => { + setPreviewOpen(false) + setPreviewMinimized(false) + setPreviewLarge(false) + setPreviewPosition({ x: 1040, y: 72 }) + setPreviewSize({ width: 600, height: 600 }) + } const { activeEditorFile, setActiveEditorFile, @@ -675,43 +783,75 @@ function LocalPcWorkspaceMockup({ const gitActive = activeIdeActivity === 'git' const settingsActive = activeIdeActivity === 'settings' const terminalActive = activeIdeActivity === 'workspace' || activeIdeActivity === 'search' - const remoteWorkspace = platformKey !== 'futuredrive-local-pc' - const worldMapStorageKey = `atnmy:lms:world-map:${courseKey}:${weekNumber}:${platformKey}` + const studentSlug = makeStudentSlug(studentEmail) + const sessionMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:selected` + const simulationMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:simulation` + const platformMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:platform` const [selectedWorldMapKey, setSelectedWorldMapKey] = useState(() => ( - window.localStorage.getItem(worldMapStorageKey) ?? 'nav2-turtlebot3-world' + window.localStorage.getItem(sessionMapStorageKey) + ?? window.localStorage.getItem(simulationMapStorageKey) + ?? window.localStorage.getItem(platformMapStorageKey) + ?? 'nav2-turtlebot3-world' )) const [savedSlamMaps, setSavedSlamMaps] = useState([]) + const [savedSlamMapsLoaded, setSavedSlamMapsLoaded] = useState(false) const [worldLayoutVersion, setWorldLayoutVersion] = useState(0) - const availableWorldMaps = useMemo(() => { - if (remoteWorkspace) { - return [ - { - key: 'nav2-platform-slam', - platformKey: 'NAV2' as const, - name: '새 SLAM', - description: '플랫폼 LiDAR로 새 지도를 생성합니다.', - worldFile: '', - viewerModel: 'turtlebot3_world' as const, - remoteSlam: true, - }, - ...savedSlamMaps, - ] - } + const sessionWorldMaps = useMemo(() => { const configuredMaps = (curriculumWeek.maps ?? []) .filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2') - if (!configuredMaps.length) return [...NAV2_MAP_OPTIONS, ...savedSlamMaps] - const localCustomMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom') - return localCustomMap && !configuredMaps.some((map) => map.key === localCustomMap.key) - ? [...configuredMaps, localCustomMap, ...savedSlamMaps] - : [...configuredMaps, ...savedSlamMaps] - }, [curriculumWeek.maps, remoteWorkspace, savedSlamMaps]) - const selectedWorldMap = availableWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? availableWorldMaps[0] + const baseMaps = [...configuredMaps] + const platformSlamMap: WorkspaceMapOption = { + key: 'nav2-platform-slam', + platformKey: 'NAV2', + name: '새 SLAM', + description: '플랫폼 LiDAR로 새 지도를 생성합니다.', + worldFile: '', + viewerModel: 'turtlebot3_world', + remoteSlam: true, + runtimeKind: 'PLATFORM_2D', + } + const maps = [...baseMaps, platformSlamMap, ...savedSlamMaps] + const keys = new Set() + return maps.filter((map) => { + if (keys.has(map.key)) return false + keys.add(map.key) + return true + }) + }, [curriculumWeek.maps, savedSlamMaps]) useEffect(() => { - const storedMapKey = window.localStorage.getItem(worldMapStorageKey) - setSelectedWorldMapKey(availableWorldMaps.some((map) => map.key === storedMapKey) - ? storedMapKey! - : availableWorldMaps[0]?.key ?? 'nav2-turtlebot3-world') - }, [worldMapStorageKey, availableWorldMaps]) + const storedMapKey = window.localStorage.getItem(sessionMapStorageKey) + ?? window.localStorage.getItem(simulationMapStorageKey) + ?? window.localStorage.getItem(platformMapStorageKey) + const requestedMapKey = selectedWorldMapKey || storedMapKey + if (!savedSlamMapsLoaded && requestedMapKey?.startsWith('saved-slam-')) return + setSelectedWorldMapKey((current) => { + const next = sessionWorldMaps.some((map) => map.key === current) + ? current + : sessionWorldMaps.some((map) => map.key === storedMapKey) + ? storedMapKey! + : sessionWorldMaps[0]?.key ?? 'nav2-turtlebot3-world' + window.localStorage.setItem(sessionMapStorageKey, next) + return next + }) + }, [platformMapStorageKey, savedSlamMapsLoaded, selectedWorldMapKey, sessionMapStorageKey, sessionWorldMaps, simulationMapStorageKey]) + const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? sessionWorldMaps[0] + const isPlatformWorldMap = selectedWorldMap?.runtimeKind === 'PLATFORM_2D' + || selectedWorldMap?.runtimeKind === 'PLATFORM_3D' + || selectedWorldMap?.remoteSlam === true + const simulationWorldMaps = useMemo(() => ( + sessionWorldMaps.filter((map) => ( + map.runtimeKind !== 'PLATFORM_2D' + && map.runtimeKind !== 'PLATFORM_3D' + && !map.remoteSlam + )) + ), [sessionWorldMaps]) + const platformWorldMaps = useMemo(() => ( + sessionWorldMaps.filter((map) => ( + map.runtimeKind === 'PLATFORM_2D' + || map.runtimeKind === 'PLATFORM_3D' + || map.remoteSlam + )) + ), [sessionWorldMaps]) const visualizationProfiles = useMemo(() => ( [ ...(curriculumWeek.environmentProfiles ?? []), @@ -770,8 +910,7 @@ function LocalPcWorkspaceMockup({ const textStrong = dark ? 'text-white' : 'text-[#111520]' const textMid = dark ? 'text-white/70' : 'text-[#4f596b]' const textDim = dark ? 'text-white/45' : 'text-[#727b8c]' - const studentSlug = makeStudentSlug(studentEmail) - const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, courseKey, weekNumber, platformKey) + const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, courseKey, weekNumber, platformKey, curriculumWeek.isTeamProject ? teamGroupId : null) const remoteRuntimeRoot = clonePathPreview const visualizationProfileAgentUrl = readNestedString(visualizationProfiles[0]?.config, ['agent', 'url']) const remoteVisualizationTarget = remoteWorkspace ? { @@ -786,6 +925,54 @@ function LocalPcWorkspaceMockup({ port: Number(sourceSettings.sshPort ?? platformSourceSettingsDefaults.sshPort ?? 22), workspaceRoot: remoteRuntimeRoot, } : null + const rosNetworkConfig = useMemo(() => { + if (!rosWorkspaceLinked) return null + if (remoteWorkspace) return { enabled: true } + if (!linkedPlatform) return null + + const linkedDefaults = normalizeStudioSourceSettings({ + ...(linkedPlatform.sourceSettings ?? {}), + agentUrl: linkedPlatform.connection?.agentUrl ?? linkedPlatform.sourceSettings?.agentUrl ?? null, + sshUser: linkedPlatform.connection?.sshUser ?? linkedPlatform.sourceSettings?.sshUser ?? null, + }) + const linkedProfile = linkedPlatform.environmentProfiles?.find((profile) => profile.week === weekNumber) ?? null + const peerHost = parseHostFromUrl(readNestedString(linkedProfile?.config, ['agent', 'url'])) + ?? parseHostFromUrl(readNestedString(linkedProfile?.config, ['bridge', 'env', 'BRIDGE_HTTP_URL'])) + ?? parseHostFromUrl(linkedDefaults.agentUrl ?? null) + if (!peerHost) return null + + const peerPort = Number(linkedDefaults.sshPort ?? 22) + return { + enabled: true, + peer: { + host: peerHost, + user: readNestedString(linkedProfile?.config, ['agent', 'sshUser']) ?? linkedDefaults.sshUser ?? undefined, + port: Number.isFinite(peerPort) && peerPort > 0 ? peerPort : 22, + }, + } + }, [linkedPlatform, remoteWorkspace, rosWorkspaceLinked, weekNumber]) + const visualizationBridgeLocation = ( + remoteWorkspace ? localWorkspaceSettings.visualizationBridgeLocation : sourceSettings.visualizationBridgeLocation + ) === 'platform' && rosWorkspaceLinked + ? 'platform' as const + : 'local' as const + const visualizationRosNetworkConfig = useMemo(() => { + if (visualizationBridgeLocation !== 'local' || !remoteWorkspace) return rosNetworkConfig + if (!remoteVisualizationTarget?.host) return rosNetworkConfig + return { enabled: true, peer: remoteVisualizationTarget } + }, [remoteVisualizationTarget, remoteWorkspace, rosNetworkConfig, visualizationBridgeLocation]) + const moduleRosNetworkConfig = useMemo(() => { + // When the bridge runs on this PC, remote modules must also advertise to + // this PC even when no local module is participating in the ROS graph. + if (remoteWorkspace && visualizationBridgeLocation === 'local' && remoteVisualizationTarget?.host) { + return { enabled: true } + } + return rosNetworkConfig + }, [remoteVisualizationTarget?.host, remoteWorkspace, rosNetworkConfig, visualizationBridgeLocation]) + const localVisualizationWorkspaceRoot = curriculumWeek.isTeamProject && teamGroupId + ? makeTeamWeekWorkspaceRoot(localWorkspaceSettings.workspaceRoot, teamGroupId, courseKey, weekNumber) + : makeWeekWorkspaceRoot(localWorkspaceSettings.workspaceRoot, studentSlug, courseKey, weekNumber) + const [visualizationPlatform, setVisualizationPlatform] = useState(null) const { visualizationStacks, visualizationStack, @@ -803,27 +990,48 @@ function LocalPcWorkspaceMockup({ } = useWorkspaceVisualization({ profiles: visualizationProfiles, clonePathPreview, - rosDomainId: sourceSettings.rosDomainId, + rosDomainId: effectiveRosDomainId, + rosNetwork: visualizationRosNetworkConfig, fallbackStack: visualizationFallbackStack, remoteWorkspace, remoteTarget: remoteVisualizationTarget, + bridgeLocation: visualizationBridgeLocation, + bridgeWorkspaceRoot: remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview, + sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`, mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key, + platform: visualizationPlatform, + visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D' + || selectedWorldMap.runtimeKind === 'PLATFORM_3D' + || selectedWorldMap.remoteSlam + ? 'real' + : 'simulation', worldLayoutVersion, onTitlebarNotice }) + const initializedVisualizationMapRef = useRef(null) useEffect(() => { - if (!selectedWorldMap?.saved) return - if (selectedWorldMap.databasePath) { - if (visualizationStack !== 'slam3d') setVisualizationStack('slam3d') - if (nav2VisualizationMode !== 'slam') setNav2VisualizationMode('slam') - return - } - if (visualizationStack !== 'nav2') setVisualizationStack('nav2') - if (nav2VisualizationMode !== 'navigation') setNav2VisualizationMode('navigation') - }, [nav2VisualizationMode, selectedWorldMap?.databasePath, selectedWorldMap?.key, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack]) + const mapIdentity = [ + selectedWorldMap?.key ?? '', + selectedWorldMap?.databasePath ?? '', + selectedWorldMap?.runtimeKind ?? '', + selectedWorldMap?.saved ? 'saved' : 'new', + ].join(':') + if (initializedVisualizationMapRef.current === mapIdentity) return + initializedVisualizationMapRef.current = mapIdentity + const navigationMap = Boolean(selectedWorldMap?.saved) + const nextMode = navigationMap ? 'navigation' : 'slam' + const nextStack = navigationMap + ? 'nav2' + : selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D' + ? 'slam3d' + : 'slam' + if (visualizationStack !== nextStack) setVisualizationStack(nextStack) + if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode) + }, [nav2VisualizationMode, selectedWorldMap?.databasePath, selectedWorldMap?.key, selectedWorldMap?.runtimeKind, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack]) const [customModules, setCustomModules] = useState>(() => ( (curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM') )) + const customModuleRuntimeTarget = remoteWorkspace ? 'PLATFORM' : 'LOCAL' const [deletedCustomModuleIds, setDeletedCustomModuleIds] = useState>(() => new Set()) useEffect(() => { setCustomModules((current) => { @@ -837,11 +1045,17 @@ function LocalPcWorkspaceMockup({ }, [curriculumWeek.modules, deletedCustomModuleIds]) const curriculumModulesWithCustom = useMemo(() => { const baseModules = (curriculumWeek.modules ?? []).filter((module) => ( - module.sourceType !== 'CUSTOM' || !deletedCustomModuleIds.has(module.id) + (module.sourceType !== 'CUSTOM' + || (module.runtimeTarget ?? 'LOCAL') === customModuleRuntimeTarget + || module.runtimeTarget === 'SHARED') + && !deletedCustomModuleIds.has(module.id) )) const knownIds = new Set(baseModules.map((module) => module.id)) - return [...baseModules, ...customModules.filter((module) => !knownIds.has(module.id))] - }, [curriculumWeek.modules, customModules, deletedCustomModuleIds]) + return [...baseModules, ...customModules.filter((module) => ( + ((module.runtimeTarget ?? 'LOCAL') === customModuleRuntimeTarget || module.runtimeTarget === 'SHARED') + && !knownIds.has(module.id) + ))] + }, [curriculumWeek.modules, customModuleRuntimeTarget, customModules, deletedCustomModuleIds]) const runtimeModules = useMemo(() => { const resolvedModules = resolveRuntimeModules( curriculumModulesWithCustom, @@ -853,17 +1067,43 @@ function LocalPcWorkspaceMockup({ const knownIds = new Set(resolvedModules.map((module) => module.id)) return [ ...resolvedModules, - ...customModules.filter((module) => !knownIds.has(module.id)), + ...customModules.filter((module) => ( + ((module.runtimeTarget ?? 'LOCAL') === customModuleRuntimeTarget || module.runtimeTarget === 'SHARED') + && !knownIds.has(module.id) + )), ].sort((left, right) => left.sortOrder - right.sortOrder) - }, [activeEnvironmentProfile, curriculumModulesWithCustom, customModules, remoteWorkspace, visualizationProfiles]) + }, [activeEnvironmentProfile, curriculumModulesWithCustom, customModuleRuntimeTarget, customModules, remoteWorkspace, visualizationProfiles]) const moduleRuntimeImages = useMemo(() => ( resolveModuleRuntimeImages(visualizationProfiles, remoteWorkspace, activeRuntimeImages) ), [activeRuntimeImages, remoteWorkspace, visualizationProfiles]) + const profileSshUser = readNestedString(activeEnvironmentProfile?.config, ['agent', 'sshUser']) + const resolveRemoteWorkspaceTarget = (): RemoteWorkspaceTarget | null => { + const profileAgentUrl = readNestedString(activeEnvironmentProfile?.config, ['agent', 'url']) + const host = parseHostFromUrl(profileAgentUrl) + ?? parseHostFromUrl(readNestedString(activeEnvironmentProfile?.config, ['bridge', 'env', 'BRIDGE_HTTP_URL'])) + ?? parseHostFromUrl(sourceSettings.agentUrl ?? null) + ?? parseHostFromUrl(platformSourceSettingsDefaults.agentUrl ?? null) + if (!host) return null + const sshPort = Number(sourceSettings.sshPort ?? platformSourceSettingsDefaults.sshPort ?? 22) + const sshUser = profileSshUser ?? sourceSettings.sshUser ?? platformSourceSettingsDefaults.sshUser + return { + host, + user: sshUser ?? undefined, + port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22, + workspaceRoot: remoteRuntimeRoot + } + } + const getRemoteWorkspaceTarget = (): RemoteWorkspaceTarget => { + const target = resolveRemoteWorkspaceTarget() + if (!target) throw new Error('원격 플랫폼 SSH 주소를 확인하지 못했습니다.') + return target + } const loadSavedSlamMaps = useCallback(async (): Promise => { + setSavedSlamMapsLoaded(false) try { const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/maps?courseId=${encodeURIComponent(courseKey)}&week=${weekNumber}`) if (!response.ok) throw new Error(`saved_maps_failed_${response.status}`) - const maps = await response.json() as Array<{ id: string; name: string; yamlPath: string; pgmPath: string; previewPgm?: string | null; databasePath?: string | null; sourceMapKey: string; worldFile: string; viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; createdAt: string }> + const maps = await response.json() as Array<{ id: string; name: string; yamlPath: string; pgmPath: string; yamlUrl?: string | null; pgmUrl?: string | null; previewPgm?: string | null; databasePath?: string | null; databaseUrl?: string | null; runtimeKind?: SavedMapRuntimeKind; sourceMapKey: string; worldFile: string; viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; createdAt: string }> setSavedSlamMaps(maps.map((map) => ({ key: `saved-slam-${map.id}`, platformKey: 'NAV2', @@ -877,11 +1117,18 @@ function LocalPcWorkspaceMockup({ sourceMapKey: map.sourceMapKey, yamlPath: map.yamlPath, pgmPath: map.pgmPath, + yamlUrl: map.yamlUrl ?? undefined, + pgmUrl: map.pgmUrl ?? undefined, previewPgm: map.previewPgm ?? undefined, databasePath: map.databasePath ?? undefined, + databaseUrl: map.databaseUrl ?? undefined, + runtimeKind: map.runtimeKind + ?? (map.databasePath ? 'PLATFORM_3D' : map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'), }))) } catch (error) { console.warn('[studio] saved SLAM maps load failed', error) + } finally { + setSavedSlamMapsLoaded(true) } }, [courseKey, fetchWithFreshAuth, weekNumber]) useEffect(() => { @@ -900,7 +1147,13 @@ function LocalPcWorkspaceMockup({ } if (payload?.source !== 'autonomy-slam-viewer') return if (payload.type === 'navigation-init') { - if (remoteWorkspace) return + const isPlatformMap = selectedWorldMap.runtimeKind === 'PLATFORM_2D' + || selectedWorldMap.runtimeKind === 'PLATFORM_3D' + || selectedWorldMap.remoteSlam + // Only the Gazebo simulation has a model pose to reset. A platform + // map can also be opened from the local workspace, where this IPC + // would incorrectly fail because no Simulator container is running. + if (remoteWorkspace || isPlatformMap) return void Promise.all([ window.localWorkspace.nav2.setSimulatorPose({ x: -2, y: -0.5, yaw: 0 }), window.localWorkspace.nav2.clearRuntimeObstacles(), @@ -977,17 +1230,41 @@ function LocalPcWorkspaceMockup({ if (payload.type !== 'map-saved' || !payload.map?.yamlPath || !payload.map.pgmPath) return void (async () => { try { - const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/maps`, { + const sourceArtifacts = [ + { name: 'yaml' as const, path: payload.map.yamlPath }, + { name: 'pgm' as const, path: payload.map.pgmPath }, + ...(payload.map.databasePath ? [{ name: 'database' as const, path: payload.map.databasePath }] : []), + ] + const artifacts = remoteWorkspace + ? await window.remoteWorkspace.maps.readArtifacts(getRemoteWorkspaceTarget(), sourceArtifacts) + : await window.localWorkspace.maps.readArtifacts(clonePathPreview, sourceArtifacts) + const form = new FormData() + form.set('courseId', courseKey) + form.set('week', String(weekNumber)) + form.set('sourceMapKey', selectedWorldMap.key) + form.set('worldFile', selectedWorldMap.worldFile) + form.set('viewerModel', selectedWorldMap.viewerModel) + form.set('runtimeKind', remoteWorkspace + ? (payload.map.databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D') + : 'SIMULATION') + form.set('yamlPath', payload.map.yamlPath) + form.set('pgmPath', payload.map.pgmPath) + if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm) + if (payload.map.databasePath) form.set('databasePath', payload.map.databasePath) + artifacts.forEach((artifact) => { + const binary = atob(artifact.base64) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + const contentType = artifact.name === 'yaml' + ? 'application/x-yaml' + : artifact.name === 'pgm' + ? 'image/x-portable-graymap' + : 'application/octet-stream' + form.set(artifact.name, new Blob([bytes], { type: contentType }), artifact.fileName) + }) + const response = await fetch(`${LMS_API_BASE_URL}/studio/maps/upload`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - courseId: courseKey, - week: weekNumber, - sourceMapKey: selectedWorldMap.key, - worldFile: selectedWorldMap.worldFile, - viewerModel: selectedWorldMap.viewerModel, - ...payload.map - }) + headers: { Authorization: `Bearer ${await getFreshAccessToken()}` }, + body: form, }) if (!response.ok) throw new Error(`saved_map_record_failed_${response.status}`) await loadSavedSlamMaps() @@ -1000,36 +1277,14 @@ function LocalPcWorkspaceMockup({ } window.addEventListener('message', handleViewerMessage) return () => window.removeEventListener('message', handleViewerMessage) - }, [courseKey, fetchWithFreshAuth, loadSavedSlamMaps, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, weekNumber]) - const profileSshUser = readNestedString(activeEnvironmentProfile?.config, ['agent', 'sshUser']) - const resolveRemoteWorkspaceTarget = (): RemoteWorkspaceTarget | null => { - const profileAgentUrl = readNestedString(activeEnvironmentProfile?.config, ['agent', 'url']) - const host = parseHostFromUrl(profileAgentUrl) - ?? parseHostFromUrl(readNestedString(activeEnvironmentProfile?.config, ['bridge', 'env', 'BRIDGE_HTTP_URL'])) - ?? parseHostFromUrl(sourceSettings.agentUrl ?? null) - ?? parseHostFromUrl(platformSourceSettingsDefaults.agentUrl ?? null) - if (!host) return null - const sshPort = Number(sourceSettings.sshPort ?? platformSourceSettingsDefaults.sshPort ?? 22) - const sshUser = profileSshUser ?? sourceSettings.sshUser ?? platformSourceSettingsDefaults.sshUser - return { - host, - user: sshUser ?? undefined, - port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22, - workspaceRoot: remoteRuntimeRoot - } - } - const getRemoteWorkspaceTarget = (): RemoteWorkspaceTarget => { - const target = resolveRemoteWorkspaceTarget() - if (!target) throw new Error('원격 플랫폼 SSH 주소를 확인하지 못했습니다.') - return target - } + }, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, weekNumber]) const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget()) const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId) const hasRestorableWorkspaceUi = Boolean( window.localStorage.getItem(workspaceUiSessionKey) ?? window.sessionStorage.getItem(workspaceUiSessionKey) ) - const [workspaceRestoreLoading, setWorkspaceRestoreLoading] = useState(hasRestorableWorkspaceUi) + const [workspaceRestoreLoading, setWorkspaceRestoreLoading] = useState(!skipInitialLoading && hasRestorableWorkspaceUi) const workspaceRestoreStartedAtRef = useRef(Date.now()) const getModuleParameterStorageKey = (module: ModuleRow): string => makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name) const applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => { @@ -1055,6 +1310,7 @@ function LocalPcWorkspaceMockup({ markModuleState, modulesLoading, moduleListError, + clearModuleListError, loadAvailableModules } = useWorkspaceModuleList({ clonePathPreview, @@ -1063,8 +1319,35 @@ function LocalPcWorkspaceMockup({ remoteWorkspace, remoteWorkspaceTargetKey, pollingActive: true, + skipInitialModuleLoad: skipInitialLoading, applyModuleParameterOverrides }) + useEffect(() => { + const readPlatform = (module: ModuleRow | null | undefined): string | null => { + const value = module?.parameters + .find((parameter) => parameter.startsWith('platform:=')) + ?.slice('platform:='.length) + .trim() + .toLowerCase() + if (!value) return null + return value === 'mk-mini' ? 'mkmini' : value + } + + // The active simulator owns the spawned robot. Prefer its parameter so + // the Three.js model cannot depend on whichever source tab was last open. + const runningSimulator = availableModules.find((module) => ( + module.state === 'running' && module.category.toLowerCase() === 'simulator' + )) + const runningVehicle = availableModules.find((module) => ( + module.state === 'running' && module.category.toLowerCase() === 'vehicle' + )) + const nextPlatform = readPlatform(runningSimulator) + ?? readPlatform(runningVehicle) + ?? readPlatform(selectedModule) + ?? moduleTabs.map(readPlatform).find((platform): platform is string => Boolean(platform)) + ?? null + setVisualizationPlatform((current) => current === nextPlatform ? current : nextPlatform) + }, [availableModules, moduleTabs, selectedModule]) const moduleListOrderStorageKey = `${workspaceUiSessionKey}:module-list-order` const [moduleListOrder, setModuleListOrder] = useState([]) useEffect(() => { @@ -1076,6 +1359,39 @@ function LocalPcWorkspaceMockup({ } }, [moduleListOrderStorageKey]) const moduleListItemKey = (module: ModuleRow): string => module.folderName ?? module.name + const moduleParkingStorageKey = `${workspaceUiSessionKey}:module-list-parking` + const moduleParkingHeightStorageKey = `${workspaceUiSessionKey}:module-list-parking-height` + const [parkedModuleKeys, setParkedModuleKeys] = useState([]) + useEffect(() => { + try { + const stored = JSON.parse(window.localStorage.getItem(moduleParkingStorageKey) ?? '[]') + setParkedModuleKeys(Array.isArray(stored) ? stored.filter((value): value is string => typeof value === 'string') : []) + } catch { + setParkedModuleKeys([]) + } + }, [moduleParkingStorageKey]) + const storeParkedModuleKeys = useCallback((keys: string[]): void => { + setParkedModuleKeys(keys) + try { + window.localStorage.setItem(moduleParkingStorageKey, JSON.stringify(keys)) + } catch { + // The parking state remains available until the workspace is closed. + } + }, [moduleParkingStorageKey]) + useEffect(() => { + if (!availableModules.length) return + const availableKeys = new Set(availableModules.map(moduleListItemKey)) + setParkedModuleKeys((current) => { + const next = current.filter((key) => availableKeys.has(key)) + if (next.length === current.length) return current + try { + window.localStorage.setItem(moduleParkingStorageKey, JSON.stringify(next)) + } catch { + // Keep the valid in-memory state when local storage is unavailable. + } + return next + }) + }, [availableModules, moduleParkingStorageKey]) const displayedModules = useMemo(() => { const defaultOrder = [...availableModules].sort((left, right) => left.sortOrder - right.sortOrder) if (!moduleListOrder.length) return defaultOrder @@ -1090,6 +1406,23 @@ function LocalPcWorkspaceMockup({ return leftIndex - rightIndex }) }, [availableModules, moduleListOrder]) + const parkedModuleKeySet = useMemo(() => new Set(parkedModuleKeys), [parkedModuleKeys]) + const activeDisplayedModules = useMemo(() => ( + displayedModules.filter((module) => !parkedModuleKeySet.has(moduleListItemKey(module))) + ), [displayedModules, parkedModuleKeySet]) + const parkedDisplayedModules = useMemo(() => ( + displayedModules.filter((module) => parkedModuleKeySet.has(moduleListItemKey(module))) + ), [displayedModules, parkedModuleKeySet]) + const parkModule = useCallback((module: ModuleRow): void => { + const key = moduleListItemKey(module) + if (parkedModuleKeySet.has(key)) return + storeParkedModuleKeys([...parkedModuleKeys, key]) + }, [parkedModuleKeySet, parkedModuleKeys, storeParkedModuleKeys]) + const restoreParkedModule = useCallback((module: ModuleRow): void => { + const key = moduleListItemKey(module) + if (!parkedModuleKeySet.has(key)) return + storeParkedModuleKeys(parkedModuleKeys.filter((item) => item !== key)) + }, [parkedModuleKeySet, parkedModuleKeys, storeParkedModuleKeys]) const reorderDisplayedModules = useCallback((source: ModuleRow, target: ModuleRow): void => { const sourceKey = moduleListItemKey(source) const targetKey = moduleListItemKey(target) @@ -1162,6 +1495,33 @@ function LocalPcWorkspaceMockup({ readGitSubmissions, readGitCommitDiff }) + const teamGitSubmissions = useMemo(() => ( + Object.entries(gitModuleStatuses) + .flatMap(([moduleName, status]) => status.commits.map((commit) => ({ + id: `team-${moduleName}-${commit.hash}`, + submittedAt: commit.date, + moduleName, + commit, + }))) + .sort((left, right) => new Date(right.submittedAt).getTime() - new Date(left.submittedAt).getTime()) + ), [gitModuleStatuses]) + const teamGitSubmissionGroups = useMemo(() => ( + teamGitSubmissions.map((submission) => ({ + id: submission.id, + submittedAt: submission.submittedAt, + submissions: [submission], + })) + ), [teamGitSubmissions]) + const sourceControlSubmissions = curriculumWeek.isTeamProject ? teamGitSubmissions : gitSubmissions + const sourceControlSubmissionGroups = curriculumWeek.isTeamProject ? teamGitSubmissionGroups : gitSubmissionGroups + useEffect(() => { + if (!curriculumWeek.isTeamProject) return + setSelectedGitSubmission((current) => ( + current && teamGitSubmissions.some((submission) => submission.moduleName === current.moduleName && submission.commit.hash === current.commit.hash) + ? current + : teamGitSubmissions[0] ?? null + )) + }, [curriculumWeek.isTeamProject, setSelectedGitSubmission, teamGitSubmissions]) const { loadModuleFiles, startCreateFile, @@ -1245,7 +1605,83 @@ function LocalPcWorkspaceMockup({ onTitlebarNotice('변경사항 되돌리기에 실패했습니다', 'error') } } - const recordLearningCompletion = async (commitCount: number, completedWeeks: number): Promise => { + const publishTeamModuleChanges = async (): Promise => { + if (teamGitSyncing) return + if (!gitChangedModules.length) { + onTitlebarNotice('팀에 반영할 변경사항이 없습니다', 'warning') + return + } + if (installedGitModules.some((module) => getDirtyModuleFiles(module.name).length)) { + onTitlebarNotice('편집 중인 파일을 먼저 저장하세요', 'warning') + return + } + + setTeamGitSyncing(true) + setGitError(null) + const authorName = studentEmail.split('@')[0] || studentEmail + try { + for (const module of gitChangedModules) { + const result = remoteWorkspace + ? await window.remoteWorkspace.git.teamPublish( + getRemoteWorkspaceTarget(), + module.name, + module.folderName ?? undefined, + `${weekNumber}주차 ${module.name} 팀 반영`, + authorName, + studentEmail, + ) + : await window.localWorkspace.git.teamPublish( + module.name, + clonePathPreview, + module.folderName, + `${weekNumber}주차 ${module.name} 팀 반영`, + authorName, + studentEmail, + ) + if (!result.ok) throw new Error(`[${module.name}] ${result.message}`) + } + await refreshGitStatuses(null) + onTitlebarNotice('팀 저장소에 반영했습니다', 'info') + } catch (error) { + console.error('[git] team publish failed', error) + setGitError(error instanceof Error ? error.message : '팀 저장소 반영에 실패했습니다.') + onTitlebarNotice('팀 저장소 반영에 실패했습니다', 'error') + } finally { + setTeamGitSyncing(false) + } + } + const pullTeamModuleChanges = async (): Promise => { + if (teamGitSyncing) return + if (!installedGitModules.length) { + onTitlebarNotice('설치된 모듈이 없습니다', 'warning') + return + } + if (installedGitModules.some((module) => getDirtyModuleFiles(module.name).length)) { + onTitlebarNotice('편집 중인 파일을 먼저 저장하세요', 'warning') + return + } + + setTeamGitSyncing(true) + setGitError(null) + try { + for (const module of installedGitModules) { + const result = remoteWorkspace + ? await window.remoteWorkspace.git.teamPull(getRemoteWorkspaceTarget(), module.name, module.folderName ?? undefined) + : await window.localWorkspace.git.teamPull(module.name, clonePathPreview, module.folderName) + if (!result.ok) throw new Error(`[${module.name}] ${result.message}`) + } + await Promise.all(installedGitModules.map((module) => loadModuleFiles(module))) + await refreshGitStatuses(null) + onTitlebarNotice('팀 변경사항을 받았습니다', 'info') + } catch (error) { + console.error('[git] team pull failed', error) + setGitError(error instanceof Error ? error.message : '팀 변경사항을 받지 못했습니다.') + onTitlebarNotice('팀 변경사항을 받지 못했습니다', 'error') + } finally { + setTeamGitSyncing(false) + } + } + const recordLearningCompletion = async (completedWeeks: number): Promise => { if (allowFutureLearningSteps) return if (!accessToken) return const requestAccessToken = await getFreshAccessToken() @@ -1259,7 +1695,8 @@ function LocalPcWorkspaceMockup({ courseId: courseKey, week: weekNumber, completedWeeks, - commitCount + commitCount: 0, + completionSubmitted: true, }) }) if (!recordResponse.ok) throw new Error(`제출 기록 저장 실패 (${recordResponse.status})`) @@ -1319,30 +1756,31 @@ function LocalPcWorkspaceMockup({ } }, [accessToken, allowFutureLearningSteps, courseKey, fetchWithFreshAuth, platformId, weekNumber]) - const submitAllModuleChanges = async (): Promise => { + const submitLearningState = async (): Promise => { if (gitSubmitting) return - if (curriculumWeek.state === 'done') { - onTitlebarNotice('이미 완료된 강좌입니다', 'warning') + if (curriculumWeek.detail.sourceSubmitted) { + onTitlebarNotice('이미 제출한 주차입니다', 'warning') return } setGitSubmitting(true) setGitError(null) - const requiresSourceSubmission = curriculumWeek.detail.requiresSourceSubmission ?? true - if (!requiresSourceSubmission) { - try { - await recordLearningCompletion(0, weekNumber) - onTitlebarNotice('학습이 완료되었습니다', 'info') - } catch (error) { - console.error('[learning] completion failed', error) - setGitError(error instanceof Error ? error.message : '학습 완료 처리에 실패했습니다.') - onTitlebarNotice('학습 완료 처리에 실패했습니다', 'error') - } finally { - setGitSubmitting(false) - } - return + try { + await recordLearningCompletion(weekNumber) + onTitlebarNotice('학습을 완료했습니다', 'info') + } catch (error) { + console.error('[learning] completion submission failed', error) + setGitError(error instanceof Error ? error.message : '학습 완료 처리에 실패했습니다.') + onTitlebarNotice('학습 완료 처리에 실패했습니다', 'error') + } finally { + setGitSubmitting(false) } + } + const pushAllModuleChanges = async (): Promise => { + if (gitSubmitting || curriculumWeek.detail.sourceSubmitted) return + setGitSubmitting(true) + setGitError(null) let statusMap = gitModuleStatuses try { const entries = await Promise.all(installedGitModules.map(async (module) => { @@ -1374,6 +1812,28 @@ function LocalPcWorkspaceMockup({ try { let submittedCommitCount = 0 for (const module of changedModules) { + let customRepositoryUrl: string | undefined + if (module.sourceType === 'CUSTOM') { + if (!module.id) throw new Error(`[${module.name}] 커스텀 모듈 식별자가 없습니다.`) + const repositoryResponse = await fetchWithFreshAuth( + `${LMS_API_BASE_URL}/studio/custom-module-repositories/prepare`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + courseId: courseKey, + week: weekNumber, + moduleId: module.id, + }), + }, + ) + if (!repositoryResponse.ok) { + throw new Error(`[${module.name}] 저장소 준비에 실패했습니다. (${repositoryResponse.status})`) + } + const repository = await repositoryResponse.json() as { repositoryUrlWithToken?: string } + customRepositoryUrl = repository.repositoryUrlWithToken?.trim() + if (!customRepositoryUrl) throw new Error(`[${module.name}] 저장소 주소를 확인하지 못했습니다.`) + } const result = remoteWorkspace ? await window.remoteWorkspace.git.submit( getRemoteWorkspaceTarget(), @@ -1382,7 +1842,8 @@ function LocalPcWorkspaceMockup({ `${weekNumber}주차 ${module.name} 제출`, undefined, undefined, - submissionId + submissionId, + customRepositoryUrl, ) : await window.localWorkspace.git.submit( module.name, @@ -1391,22 +1852,18 @@ function LocalPcWorkspaceMockup({ `${weekNumber}주차 ${module.name} 제출`, undefined, undefined, - submissionId + submissionId, + customRepositoryUrl, ) if (!result.ok) throw new Error(`[${module.name}] ${result.message}`) if (result.commit) submittedCommitCount += 1 } - if (submittedCommitCount > 0) { - const completedWeeks = curriculumWeek.progress >= 100 ? weekNumber : Math.max(0, weekNumber - 1) - await recordLearningCompletion(submittedCommitCount, completedWeeks) - } - - onTitlebarNotice('변경사항이 제출되었습니다', 'info') + onTitlebarNotice(submittedCommitCount > 0 ? '변경사항을 제출했습니다' : '변경사항을 반영했습니다', 'info') await refreshGitStatuses(null) await refreshGitSubmissions() } catch (error) { - console.error('[git] submit failed', error) + console.error('[git] submission failed', error) setGitError(error instanceof Error ? error.message : '제출에 실패했습니다.') onTitlebarNotice('제출에 실패했습니다', 'error') } finally { @@ -1617,7 +2074,7 @@ function LocalPcWorkspaceMockup({ window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) } - const startPreviewResize = (event: React.MouseEvent, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'): void => { + const startPreviewResize = (event: React.MouseEvent, direction: VisualizationResizeDirection): void => { if (previewLarge) return event.preventDefault() event.stopPropagation() @@ -1669,6 +2126,49 @@ function LocalPcWorkspaceMockup({ 'grid h-6 w-6 shrink-0 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]' ].join(' ') + useEffect(() => { + onVisualizationHostChange?.({ + instanceId: visualizationHostInstanceId, + ownerPlatformId: platformId, + dark, + textStrong, + textDim, + iconButtonClass, + available: visualizationStacks.length > 0, + running: visualizationRunning, + busy: visualizationBusy, + viewerUrl: visualizationViewerUrl, + onStart: () => startVisualizationBridge(), + onTerminate: async () => { + await stopVisualizationBridge() + closeVisualizationPanel() + }, + onDragStart: startPreviewDrag, + onToggleLarge: () => setPreviewLarge((value) => !value), + onMinimize: () => { + setPreviewMinimized(true) + setPreviewOpen(false) + }, + onClose: closeVisualizationPanel, + onResizeStart: startPreviewResize + }) + }, [ + dark, + iconButtonClass, + onVisualizationHostChange, + platformId, + startPreviewDrag, + startPreviewResize, + startVisualizationBridge, + stopVisualizationBridge, + textDim, + textStrong, + visualizationBusy, + visualizationRunning, + visualizationStacks.length, + visualizationViewerUrl, + visualizationHostInstanceId, + ]) const topToggleButtonClass = (active: boolean): string => [ 'flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 transition', active @@ -1824,7 +2324,13 @@ function LocalPcWorkspaceMockup({ giteaBaseUrl: sourceSettings.giteaBaseUrl, giteaOwner: sourceSettings.giteaOwner, gpuEnabled: sourceSettingsDraft.gpuEnabled, - rosDomainId: sourceSettingsDraft.rosDomainId.trim() || defaultStudioSourceSettings.rosDomainId + editorWordWrap: sourceSettingsDraft.editorWordWrap, + editorFontSize: sourceSettingsDraft.editorFontSize, + editorTabSize: sourceSettingsDraft.editorTabSize, + editorMinimap: sourceSettingsDraft.editorMinimap, + rosDomainId: sourceSettingsDraft.rosDomainId.trim() || defaultStudioSourceSettings.rosDomainId, + rosLinkedPlatformId: sourceSettingsDraft.rosLinkedPlatformId ?? null, + visualizationBridgeLocation: sourceSettingsDraft.visualizationBridgeLocation ?? 'local', }) const workspaceRootChanged = normalizedSettings.workspaceRoot !== sourceSettings.workspaceRoot const hasRunningModules = availableModules.some((module) => module.state === 'running') @@ -1871,6 +2377,51 @@ function LocalPcWorkspaceMockup({ saveDirtyFilesForModule, onRegisterStopRunningModules }) + const prepareSavedMap = useCallback(async (): Promise<{ savedMapYamlPath?: string; savedMapDatabasePath?: string }> => { + if (!selectedWorldMap.saved) return {} + if (!selectedWorldMap.yamlPath || !selectedWorldMap.pgmPath || !selectedWorldMap.yamlUrl || !selectedWorldMap.pgmUrl) { + throw new Error('이전 형식으로 저장된 맵입니다. SLAM을 다시 저장해 공용 맵으로 변환하세요.') + } + const fileName = (path: string): string => { + const parts = path.split('/').filter(Boolean) + return parts[parts.length - 1] ?? '' + } + const yamlFileName = fileName(selectedWorldMap.yamlPath) + const pgmFileName = fileName(selectedWorldMap.pgmPath) + if (!yamlFileName.endsWith('.yaml') || !pgmFileName.endsWith('.pgm')) { + throw new Error('저장된 맵 파일 정보가 올바르지 않습니다.') + } + const artifacts: SavedMapArtifactDownload[] = [ + { name: 'yaml' as const, fileName: yamlFileName, url: selectedWorldMap.yamlUrl }, + { name: 'pgm' as const, fileName: pgmFileName, url: selectedWorldMap.pgmUrl }, + ] + let databasePath: string | undefined + if (selectedWorldMap.databasePath || selectedWorldMap.databaseUrl) { + const databaseFileName = selectedWorldMap.databasePath ? fileName(selectedWorldMap.databasePath) : '' + if (!databaseFileName.endsWith('.db') || !selectedWorldMap.databaseUrl) { + throw new Error('저장된 3D SLAM 데이터가 완전하지 않습니다.') + } + artifacts.push({ name: 'database', fileName: databaseFileName, url: selectedWorldMap.databaseUrl }) + databasePath = `/workspace/maps/${databaseFileName}` + } + if (remoteWorkspace) { + await window.remoteWorkspace.maps.sync(getRemoteWorkspaceTarget(), artifacts) + } else { + await window.localWorkspace.maps.sync(clonePathPreview, artifacts) + } + return { + savedMapYamlPath: `/workspace/maps/${yamlFileName}`, + savedMapDatabasePath: databasePath, + } + }, [clonePathPreview, getRemoteWorkspaceTarget, remoteWorkspace, selectedWorldMap]) + const vehicleRuntimeProfiles = useMemo(() => { + const profiles = new Map>() + platformOptions.forEach((platform) => { + const profile = platform.vehicleRuntimeProfile + if (profile && !profiles.has(profile.profileKey)) profiles.set(profile.profileKey, profile) + }) + return Array.from(profiles.values()) + }, [platformOptions]) const { installingModuleName, setInstallingModuleName, @@ -1892,41 +2443,43 @@ function LocalPcWorkspaceMockup({ weekNumber, clonePathPreview, remoteWorkspace, - sourceSettings, + sourceSettings: runtimeSourceSettings, + rosNetwork: moduleRosNetworkConfig, simulatorWorldFile: selectedWorldMap.worldFile, savedMapYamlPath: selectedWorldMap.saved ? selectedWorldMap.yamlPath : null, savedMapDatabasePath: selectedWorldMap.saved ? selectedWorldMap.databasePath : null, + prepareSavedMap, + bulkExcludedModuleKeys: parkedModuleKeys, activeRuntimeImages: moduleRuntimeImages, + vehicleRuntimeProfiles, getFreshAccessToken, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, onStopVisualization: async () => { await stopVisualizationBridge() setPreviewOpen(false) + setPreviewMinimized(false) }, onTitlebarNotice }) useEffect(() => { - onRegisterRunningModuleNames?.(() => availableModules + const runningModuleNames = availableModules .filter((module) => module.state === 'running') - .map((module) => module.name)) + .map((module) => module.name) + onRegisterRunningModuleNames?.(() => runningModuleNames) + onRunningModuleCountChange?.(runningModuleNames.length) return () => onRegisterRunningModuleNames?.(null) - }, [availableModules, onRegisterRunningModuleNames]) + }, [availableModules, onRegisterRunningModuleNames, onRunningModuleCountChange]) const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise => { + window.localStorage.setItem(sessionMapStorageKey, map.key) + setSelectedWorldMapKey(map.key) if (map.custom && map.layout && !remoteWorkspace) { await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout) setWorldLayoutVersion((version) => version + 1) } - window.localStorage.setItem(worldMapStorageKey, map.key) - setSelectedWorldMapKey(map.key) if (map.saved) { - if (map.databasePath) { - setNav2VisualizationMode('slam') - setVisualizationStack('slam3d') - } else { - setNav2VisualizationMode('navigation') - setVisualizationStack('nav2') - } + setNav2VisualizationMode('navigation') + setVisualizationStack('nav2') } else { setNav2VisualizationMode('slam') if (map.remoteSlam && visualizationStacks.includes('slam3d')) setVisualizationStack('slam3d') @@ -1947,16 +2500,19 @@ function LocalPcWorkspaceMockup({ } } const deleteModule = async (module: ModuleRow, options: { deleteRepository?: boolean } = {}): Promise => { - if (module.state !== 'stopped') { + const uninstalledCustomModule = module.sourceType === 'CUSTOM' && module.state === 'uninstalled' + if (module.state !== 'stopped' && !uninstalledCustomModule) { onTitlebarNotice(`[${module.name}] 정지된 모듈만 삭제할 수 있습니다.`, 'warning') return } setModuleInstallError(null) try { - if (remoteWorkspace) { - await window.remoteWorkspace.moduleFiles.delete(getRemoteWorkspaceTarget(), module.name, `modules/${module.name}`, module.folderName) - } else { - await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName) + if (!uninstalledCustomModule) { + if (remoteWorkspace) { + await window.remoteWorkspace.moduleFiles.delete(getRemoteWorkspaceTarget(), module.name, `modules/${module.name}`, module.folderName) + } else { + await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName) + } } if (module.sourceType === 'CUSTOM') { if (!module.id) throw new Error('커스텀 모듈 식별자가 없습니다.') @@ -2033,9 +2589,13 @@ function LocalPcWorkspaceMockup({ void refreshGitStatuses(selectedGitTarget) }, [gitActive, clonePathPreview, remoteWorkspaceTargetKey, refreshGitStatuses]) useEffect(() => { - if (!gitActive || gitPanelTab !== 'history') return - void refreshGitSubmissions() - }, [gitActive, gitPanelTab, clonePathPreview, remoteWorkspaceTargetKey, refreshGitSubmissions]) + if (!gitActive) return + if (curriculumWeek.isTeamProject) { + void refreshGitStatuses(selectedGitTarget) + return + } + if (gitPanelTab === 'history') void refreshGitSubmissions() + }, [curriculumWeek.isTeamProject, gitActive, gitPanelTab, clonePathPreview, refreshGitStatuses, refreshGitSubmissions, remoteWorkspaceTargetKey, selectedGitTarget]) const completeWorkspaceRestoreLoading = (): void => { const remainingMs = Math.max(0, 2000 - (Date.now() - workspaceRestoreStartedAtRef.current)) window.setTimeout(() => setWorkspaceRestoreLoading(false), remainingMs) @@ -2570,7 +3130,12 @@ function LocalPcWorkspaceMockup({ const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ courseId: courseKey, week: weekNumber, ...input }), + body: JSON.stringify({ + courseId: courseKey, + week: weekNumber, + runtimeTarget: customModuleRuntimeTarget, + ...input, + }), }) const payload = await response.json().catch(() => null) as { message?: string @@ -2582,6 +3147,7 @@ function LocalPcWorkspaceMockup({ dockerImage?: string | null packageName?: string launchFile?: string + runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' } | null if (!response.ok || !payload?.id || !payload.folderName || !payload.name || !payload.category) { throw new Error(payload?.message ?? '모듈을 추가하지 못했습니다.') @@ -2599,6 +3165,7 @@ function LocalPcWorkspaceMockup({ dockerImage: payload.dockerImage ?? null, packageName: payload.packageName ?? input.packageName, launchFile: payload.launchFile ?? input.launchFile, + runtimeTarget: payload.runtimeTarget ?? customModuleRuntimeTarget, } as const if (remoteWorkspace) { await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule) @@ -2635,8 +3202,8 @@ function LocalPcWorkspaceMockup({ controlCountdownDanger={Boolean(controlCountdownDanger)} canExtendControl={canExtendControl} controlEndsAt={controlEndsAt ?? null} - submitVisible={curriculumWeek.state !== 'done'} - submitDisabled={gitSubmitting || ((curriculumWeek.detail.requiresSourceSubmission ?? true) && installedGitModules.length === 0)} + submitVisible={!curriculumWeek.isTeamProject && curriculumWeek.state !== 'done' && !curriculumWeek.detail.sourceSubmitted} + submitDisabled={gitSubmitting} submitting={gitSubmitting} topbarClassName={topbar} topToggleButtonClass={topToggleButtonClass} @@ -2649,39 +3216,20 @@ function LocalPcWorkspaceMockup({ return nextValue }) }} - onTogglePreview={() => setPreviewOpen((value) => !value)} + onTogglePreview={() => { + if (previewMinimized) { + setPreviewMinimized(false) + setPreviewOpen(true) + return + } + setPreviewOpen((value) => !value) + }} onSubmit={() => setSubmitConfirmOpen(true)} onOpenPowerActions={() => setPowerActionModalOpen(true)} onRequestControlExtension={onRequestControlExtension} onClose={requestWorkspaceClose} /> - {previewOpen && ( - setPreviewLarge((value) => !value)} - onClose={() => setPreviewOpen(false)} - onStackChange={setVisualizationStack} - onStart={() => void startVisualizationBridge()} - onStop={() => void stopVisualizationBridge()} - onResizeStart={startPreviewResize} - /> - )} - {treeDialog && ( setSubmitConfirmOpen(false)} onConfirm={() => { setSubmitConfirmOpen(false) - void submitAllModuleChanges() + void submitLearningState() }} /> )} @@ -2811,6 +3358,7 @@ function LocalPcWorkspaceMockup({ activeActivity={activeIdeActivity} selectedModuleExists={Boolean(selectedModule)} installedGitModuleCount={installedGitModules.length} + teamProject={Boolean(curriculumWeek.isTeamProject)} moduleTabCount={moduleTabs.length} gitChangedFileCount={gitChangedFileCount} workspaceNavFlash={workspaceNavFlash} @@ -2830,7 +3378,7 @@ function LocalPcWorkspaceMockup({ textStrong={textStrong} mode={settingsActive ? 'settings' : exampleActive ? 'examples' : gitActive ? 'git' : !selectedModule ? 'empty' : 'module'} gitPanelTab={gitPanelTab} - gitSubmissionCount={gitSubmissionGroups.length} + gitSubmissionCount={sourceControlSubmissionGroups.length} gitChangedFileCount={gitChangedFileCount} > platform.platformKey !== 'futuredrive-local-pc')} onUpdateSourceSetting={updateSourceSetting} - onResetSourceSettings={setSourceSettingsDraft} onSaveSourceSettings={saveSourceSettings} /> ) : gitActive ? ( @@ -2969,6 +3516,11 @@ function LocalPcWorkspaceMockup({ textStrong={textStrong} textMid={textMid} textDim={textDim} + teamProject={Boolean(curriculumWeek.isTeamProject)} + teamSyncing={teamGitSyncing} + gitSubmitting={gitSubmitting} + learningSubmitted={Boolean(curriculumWeek.detail.sourceSubmitted)} + hasInstalledGitModules={installedGitModules.length > 0} gitPanelTab={gitPanelTab} gitChangedFileCount={gitChangedFileCount} gitChangedModules={gitChangedModules} @@ -2977,15 +3529,20 @@ function LocalPcWorkspaceMockup({ gitLoading={gitLoading} gitSubmissionsLoading={gitSubmissionsLoading} gitSubmissionDiffLoading={gitSubmissionDiffLoading} - gitSubmissions={gitSubmissions} - gitSubmissionGroups={gitSubmissionGroups} + gitSubmissions={sourceControlSubmissions} + gitSubmissionGroups={sourceControlSubmissionGroups} selectedGitTarget={selectedGitTarget} selectedGitFile={selectedGitFile} selectedGitStatus={selectedGitStatus} selectedGitSubmission={selectedGitSubmission} selectedGitSubmissionDiff={selectedGitSubmissionDiff} onGitPanelTabChange={setGitPanelTab} - onRefresh={() => gitPanelTab === 'history' ? void refreshGitSubmissions() : void refreshGitStatuses(selectedGitTarget)} + onRefresh={() => curriculumWeek.isTeamProject || gitPanelTab === 'source' + ? void refreshGitStatuses(selectedGitTarget) + : void refreshGitSubmissions()} + onTeamPublish={() => void publishTeamModuleChanges()} + onTeamPull={() => void pullTeamModuleChanges()} + onCommitPush={() => void pushAllModuleChanges()} onSelectGitFile={(moduleName, filePath) => void selectGitFile(moduleName, filePath)} onOpenGitChangedFile={openGitChangedFile} onOpenGitFileContextMenu={(module, file, x, y) => { @@ -3042,8 +3599,6 @@ function LocalPcWorkspaceMockup({ onOpenFile={openEditorFile} onCloseFile={closeEditorTab} onContextMenu={(file, x, y) => { - if (file.startsWith('examples/example-code/')) setActiveExampleEditorFile(file) - else setActiveEditorFile(file) setTabContextMenu({ file, x, y }) }} onDragStart={(file, index, event) => { @@ -3110,6 +3665,10 @@ function LocalPcWorkspaceMockup({ activeImagePreviewSource={activeImagePreviewSource} editorLanguage={editorLanguage} editorValue={currentActiveEditorFile ? editorFiles[currentActiveEditorFile] : undefined} + wordWrap={sourceSettings.editorWordWrap} + fontSize={sourceSettings.editorFontSize} + tabSize={sourceSettings.editorTabSize} + minimapEnabled={sourceSettings.editorMinimap} exampleActive={exampleActive} beforeMount={configureMonacoTheme} onMount={handleEditorMount} @@ -3136,17 +3695,26 @@ function LocalPcWorkspaceMockup({
{ + clearModuleListError() + setModuleInstallError(null) + }} + selectedMap={selectedWorldMap} + showSelectedMapPreview={Boolean(curriculumWeek.maps?.some((map) => map.platformKey === 'NAV2'))} allowRepositoryDeletion={!remoteWorkspace} + allowCustomModuleCreation={!remoteWorkspace} onAddModuleTab={addModuleTabOnly} onOpenSource={openModuleSource} onInstallModule={(module) => void installModule(module)} onDeleteModule={(module, options) => void deleteModule(module, options)} + getStoredParameters={(module) => readStoredModuleParameters()[getModuleParameterStorageKey(module)]} onUpdateModuleParameters={(module, rosParams) => void updateModuleParameters(module, rosParams)} onStartModule={(module) => void runModuleControl(module, 'start')} onBuildModule={(module) => void runModuleControl(module, 'build')} @@ -3156,6 +3724,9 @@ function LocalPcWorkspaceMockup({ onStartAll={() => void startAllModules()} onStopAll={() => void stopAllModules()} onReorderModules={reorderDisplayedModules} + onParkModule={parkModule} + onRestoreParkedModule={restoreParkedModule} + parkingHeightStorageKey={moduleParkingHeightStorageKey} onOpenWorldEditor={() => { setWorldMapSelectorOpen(true) }} @@ -3167,20 +3738,24 @@ function LocalPcWorkspaceMockup({ )} {worldMapSelectorOpen && ( setWorldMapSelectorOpen(false)} - onCreate={remoteWorkspace ? undefined : () => { + onCreate={() => { + if (remoteWorkspace) { + onTitlebarNotice('맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다.', 'warning') + return + } setWorldMapSelectorOpen(false) setWorldEditorOpen(true) }} onDelete={(map) => void deleteSavedSlamMap(map)} - onSelect={(map: WorkspaceMapOption) => { + onSelect={(map) => { + if (map.key === selectedWorldMapKey) return setWorldMapSelectorOpen(false) - if (map.key === selectedWorldMap.key) return if (availableModules.some((module) => module.state === 'running')) { void applyWorldMapSelection(map) setPendingWorldMapRestart(map) @@ -3278,6 +3853,7 @@ export default function App(): React.JSX.Element { const getRunningWorkspaceModuleNamesRef = useRef<(() => string[]) | null>(null) const restoredStudioSession = restoredStudioSessionRef.current const [curriculumWeeks, setCurriculumWeeks] = useState([]) + const [studentGroup, setStudentGroup] = useState<{ id: string; name: string } | null>(null) const [courseKey, setCourseKey] = useState('default-course') const [studentCourses, setStudentCourses] = useState([]) const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false) @@ -3289,6 +3865,42 @@ export default function App(): React.JSX.Element { const [curriculumRefreshSignal, setCurriculumRefreshSignal] = useState(0) const [learningStats, setLearningStats] = useState([]) const [platformOptions, setPlatformOptions] = useState([]) + const [workspaceVisualizationOpen, setWorkspaceVisualizationOpen] = useState(false) + const [workspaceVisualizationMinimized, setWorkspaceVisualizationMinimized] = useState(false) + const [workspaceVisualizationLarge, setWorkspaceVisualizationLarge] = useState(false) + const [workspaceVisualizationPosition, setWorkspaceVisualizationPosition] = useState({ x: 1040, y: 72 }) + const [workspaceVisualizationSize, setWorkspaceVisualizationSize] = useState({ width: 600, height: 600 }) + const workspaceVisualizationHostRef = useRef(null) + const [workspaceVisualizationHost, setWorkspaceVisualizationHost] = useState(null) + const handleWorkspaceVisualizationHostChange = useCallback((nextHost: WorkspaceVisualizationHost): void => { + const currentHost = workspaceVisualizationHostRef.current + // The visualization window owns the workspace instance that created it. + // Returning to a previously visited workspace mounts a new instance, which + // must not replace the existing iframe while the window is still open. + if ( + currentHost + && (workspaceVisualizationOpen || workspaceVisualizationMinimized) + && currentHost.instanceId !== nextHost.instanceId + ) return + + workspaceVisualizationHostRef.current = nextHost + setWorkspaceVisualizationHost((current) => { + if ( + current + && current.instanceId === nextHost.instanceId + && current.ownerPlatformId === nextHost.ownerPlatformId + && current.dark === nextHost.dark + && current.textStrong === nextHost.textStrong + && current.textDim === nextHost.textDim + && current.iconButtonClass === nextHost.iconButtonClass + && current.available === nextHost.available + && current.running === nextHost.running + && current.busy === nextHost.busy + && current.viewerUrl === nextHost.viewerUrl + ) return current + return nextHost + }) + }, [workspaceVisualizationMinimized, workspaceVisualizationOpen]) const [downloadResources, setDownloadResources] = useState([]) const [authSession, setAuthSession] = useState(null) const authSessionRef = useRef(null) @@ -3324,7 +3936,10 @@ export default function App(): React.JSX.Element { const [sessionEndedAt, setSessionEndedAt] = useState(restoredStudioSession?.sessionEndedAt ?? null) const [connectedPlatform, setConnectedPlatform] = useState(null) const [runningPlatforms, setRunningPlatforms] = useState([]) + const [runningModuleCounts, setRunningModuleCounts] = useState>({}) + const [runningModuleNamesByPlatform, setRunningModuleNamesByPlatform] = useState>({}) const [lastWorkspacePlatform, setLastWorkspacePlatform] = useState(null) + const [skipWorkspaceInitialLoading, setSkipWorkspaceInitialLoading] = useState(false) const connectedPlatformRef = useRef(null) const runningPlatformsRef = useRef([]) const platformStatusRequestSequenceRef = useRef(0) @@ -3335,6 +3950,7 @@ export default function App(): React.JSX.Element { const [moduleShutdownConfirm, setModuleShutdownConfirm] = useState<{ action: 'disconnect' | 'release' moduleNames: string[] + platformToClose?: PlatformOption } | null>(null) const [controlExtensionConfirmOpen, setControlExtensionConfirmOpen] = useState(false) const [controlExtensionSubmitting, setControlExtensionSubmitting] = useState(false) @@ -3343,7 +3959,11 @@ export default function App(): React.JSX.Element { const instructorAccount = isInstructorAccount(authSession?.user) const openContextHelp = (): void => { - const steps = getContextHelpSteps({ platformModalOpen, localPcLayoutOpen }) + const steps = getContextHelpSteps({ + platformModalOpen, + localPcLayoutOpen, + aiGuideOpen: outerView === 'guide' + }) const availableSteps = steps.filter((step) => typeof step.element === 'string' && document.querySelector(step.element)) const helpDriver = driver({ @@ -3368,6 +3988,7 @@ export default function App(): React.JSX.Element { } const selectedWeek = selectedWeekState ?? EMPTY_WEEK const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK + const teamWorkspaceGroupId = workspaceWeek.isTeamProject ? studentGroup?.id ?? null : null const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId) ?? null const publicGuideCourseActive = selectedCourse?.slug === PUBLIC_GUIDE_COURSE_SLUG const studioDisabled = !selectedWeekState @@ -3393,12 +4014,12 @@ export default function App(): React.JSX.Element { setActiveLearningStep(firstStep) }, [activeLearningStep, publicGuideCourseActive, selectedWeekState]) const curriculumSummary = curriculumWeeks.length ? `${curriculumWeeks.length}개 섹션` : '등록된 섹션 없음' - const showTitlebarNotice = (title: string, tone: 'info' | 'warning' | 'error' = 'info', addToList = false): void => { + const showTitlebarNotice = useCallback((title: string, tone: 'info' | 'warning' | 'error' = 'info', addToList = false): void => { setTitlebarNotices((notices) => [ ...notices.slice(-49), { id: Date.now() + Math.random(), title, tone, addToList } ]) - } + }, []) const applyAuthSession = useCallback((session: LmsAuthSession): void => { authSessionRef.current = session storeAuthSession(session) @@ -3449,6 +4070,7 @@ export default function App(): React.JSX.Element { courseKey, workspaceWeek.week, platform.platformKey, + teamWorkspaceGroupId, ) const profileAgentUrl = readNestedString(profile?.config, ['agent', 'url']) const host = parseHostFromUrl(profileAgentUrl) @@ -3679,6 +4301,7 @@ export default function App(): React.JSX.Element { } previousWeekStateRef.current = Object.fromEntries(nextWeeks.map((week) => [week.week, week.state])) setCourseKey(payload?.course?.id ?? payload?.course?.slug ?? 'default-course') + setStudentGroup(payload?.studentGroup ?? null) setCurriculumWeeks(nextWeeks) setActiveLearningStep(payload?.activeLearningStep ?? null) setLearningStats(payload?.learningStats ?? []) @@ -3699,6 +4322,7 @@ export default function App(): React.JSX.Element { setCourseKey('default-course') setLearningStats([]) setPlatformOptions([]) + setStudentGroup(null) setSelectedWeekState(null) } finally { const remainingMs = Math.max(0, 500 - (Date.now() - curriculumLoadingStartedAt)) @@ -3918,7 +4542,8 @@ export default function App(): React.JSX.Element { } }, [authReady, authSession?.accessToken, fetchWithFreshAuth]) const isRemotePlatformStatusPending = (platform: PlatformOption): boolean => ( - platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(platform.id) + platform.platformKey !== 'futuredrive-local-pc' + && !checkedRemotePlatformStatusIds.has(platform.id) ) const filteredPlatformOptions = platformOptions.filter((platform) => { const pending = isRemotePlatformStatusPending(platform) @@ -4060,6 +4685,7 @@ export default function App(): React.JSX.Element { const sidebarRunningSessions = runningPlatforms.map((platform) => ({ id: platform.id, title: platform.title, + runningModuleCount: runningModuleCounts[platform.id] ?? 0, startedAt: platform.platformKey === 'futuredrive-local-pc' ? sessionStartedAt : formatSessionClock(platform.control?.session?.startedAt), @@ -4158,6 +4784,14 @@ export default function App(): React.JSX.Element { return () => window.removeEventListener('keydown', handleEscape) }, [completedCurriculumNotice, controlWarningNotice, courseChangeBlockedOpen, instructorStudioModalOpen, materialModalOpen, moduleShutdownConfirm, platformModalMode, platformModalOpen, recentSessionModalOpen, runningPlatformCloseTarget, sessionConfirmAction, weekSwitchBlockedOpen]) const removePlatformSession = (platformToRemove: PlatformOption, options: { clearWorkspace: boolean }): void => { + setRunningModuleCounts((counts) => { + const { [platformToRemove.id]: _removed, ...remainingCounts } = counts + return remainingCounts + }) + setRunningModuleNamesByPlatform((platforms) => { + const { [platformToRemove.id]: _removed, ...remainingPlatforms } = platforms + return remainingPlatforms + }) if (options.clearWorkspace) { const studentSlug = makeStudentSlug(authSession?.user.email) const workspaceKey = makeWorkspaceUiSessionKey(studentSlug, workspaceWeek.week, platformToRemove.id) @@ -4235,7 +4869,32 @@ export default function App(): React.JSX.Element { void handleForcedPlatformTermination(payload) } }) - const closeRunningPlatformSession = async (platformToClose: PlatformOption): Promise => { + const getRunningModuleNamesForPlatform = async (platform: PlatformOption): Promise => { + if (platform.id === connectedPlatformRef.current?.id) { + return getRunningWorkspaceModuleNamesRef.current?.() ?? [] + } + + if (Object.prototype.hasOwnProperty.call(runningModuleNamesByPlatform, platform.id)) { + return runningModuleNamesByPlatform[platform.id] + } + if (platform.platformKey === 'futuredrive-local-pc') return [] + + const target = makeRemoteWorkspaceTargetForPlatform(platform) + if (!target) return [] + try { + const result = await window.remoteWorkspace.modules.list(target, workspaceWeek.modules ?? []) + const moduleNames = result.modules + .filter((module) => module.state === 'running') + .map((module) => module.name) + setRunningModuleNamesByPlatform((platforms) => ({ ...platforms, [platform.id]: moduleNames })) + setRunningModuleCounts((counts) => ({ ...counts, [platform.id]: moduleNames.length })) + return moduleNames + } catch (error) { + console.warn('[modules] unable to confirm running modules before session close', error) + return [] + } + } + const closeRunningPlatformSession = async (platformToClose: PlatformOption, options: { stopModules?: boolean } = {}): Promise => { if (platformLoading) return setPlatformLoadingCopy({ @@ -4246,12 +4905,14 @@ export default function App(): React.JSX.Element { const loadingStartedAt = Date.now() const closeConnectedWorkspace = connectedPlatform?.id === platformToClose.id try { - if (closeConnectedWorkspace || platformToClose.platformKey === 'futuredrive-local-pc') { + if (options.stopModules !== false && (closeConnectedWorkspace || platformToClose.platformKey === 'futuredrive-local-pc')) { await stopConnectedWorkspaceModules() - await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' })) - } else { + } else if (options.stopModules !== false) { await stopRemotePlatformModules(platformToClose) } + if (closeConnectedWorkspace) { + await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' })) + } } finally { const remainingMs = Math.max(0, 1200 - (Date.now() - loadingStartedAt)) window.setTimeout(() => { @@ -4362,6 +5023,14 @@ export default function App(): React.JSX.Element { handleManualSessionEnd(action, false) } + const confirmRunningPlatformClose = async (platform: PlatformOption): Promise => { + const moduleNames = await getRunningModuleNamesForPlatform(platform) + if (moduleNames.length > 0) { + setModuleShutdownConfirm({ action: 'disconnect', moduleNames, platformToClose: platform }) + return + } + await closeRunningPlatformSession(platform, { stopModules: false }) + } const handleExtendControl = async (): Promise => { if (!connectedPlatform || !canExtendConnectedPlatform || controlExtensionSubmitting) return @@ -4528,9 +5197,11 @@ export default function App(): React.JSX.Element { return } if (platform.platformKey !== 'futuredrive-local-pc') { - void openPlatformWorkspace(platform, '플랫폼 제어 세션과 개발 환경 연결 상태를 확인하고 있습니다.') + setSkipWorkspaceInitialLoading(true) + void openPlatformWorkspace(platform, '플랫폼 연결 상태를 확인하고 있습니다.', { reuseRunningSession: true }) return } + setSkipWorkspaceInitialLoading(true) setConnectedPlatform(platform) setLastWorkspacePlatform(platform) setStudioEntered(true) @@ -4640,11 +5311,31 @@ export default function App(): React.JSX.Element { window.setTimeout(resolve, Math.max(0, minimumMs - (Date.now() - startedAt))) }) - const openPlatformWorkspace = async (platform: PlatformOption, description: string): Promise => { + const openPlatformWorkspace = async ( + platform: PlatformOption, + description: string, + options: { reuseRunningSession?: boolean } = {}, + ): Promise => { if (instructorAccount) { showInstructorStudioGuide() return } + if (!options.reuseRunningSession) setSkipWorkspaceInitialLoading(false) + if (options.reuseRunningSession) { + setConnectedPlatform(platform) + setLastWorkspacePlatform(platform) + setWorkspaceWeekState(selectedWeekState) + setRunningPlatforms((platforms) => { + const exists = platforms.some((current) => current.id === platform.id) + return exists ? platforms : [...platforms, platform] + }) + setStudioEntered(true) + setPlatformModalOpen(false) + setRecentSessionModalOpen(false) + setSidebarCollapsed(true) + setLocalPcLayoutOpen(true) + return + } setPlatformLoadingCopy({ title: `${platform.title} 연결 준비 중`, description @@ -4684,7 +5375,7 @@ export default function App(): React.JSX.Element { return } if (platform.control?.status === 'ACTIVE') { - void openPlatformWorkspace(platform, '플랫폼 제어 세션과 개발 환경 연결 상태를 확인하고 있습니다.') + void openPlatformWorkspace(platform, '플랫폼 연결 상태를 확인하고 있습니다.') return } if (!authSession?.accessToken) return @@ -4771,7 +5462,7 @@ export default function App(): React.JSX.Element { }, [bootLoadingMessage]) return ( -
+
{ getRunningWorkspaceModuleNamesRef.current = handler + if (!handler) return + const moduleNames = handler() + const platformId = workspacePlatform.id + setRunningModuleNamesByPlatform((platforms) => { + const previous = platforms[platformId] + if (previous?.length === moduleNames.length && previous.every((name, index) => name === moduleNames[index])) { + return platforms + } + return { ...platforms, [platformId]: moduleNames } + }) + }} + onRunningModuleCountChange={(count) => { + setRunningModuleCounts((counts) => ( + counts[workspacePlatform.id] === count + ? counts + : { ...counts, [workspacePlatform.id]: count } + )) }} canReleaseControl={canReleaseConnectedPlatform} canExtendControl={canExtendConnectedPlatform} @@ -4856,6 +5564,18 @@ export default function App(): React.JSX.Element { platformKey={workspacePlatform?.platformKey ?? 'futuredrive-local-pc'} platformEnvironmentProfiles={workspacePlatform?.environmentProfiles} platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride} + platformOptions={platformOptions} + skipInitialLoading={skipWorkspaceInitialLoading} + visualizationOpen={workspaceVisualizationOpen} + onVisualizationOpenChange={setWorkspaceVisualizationOpen} + visualizationMinimized={workspaceVisualizationMinimized} + onVisualizationMinimizedChange={setWorkspaceVisualizationMinimized} + visualizationLarge={workspaceVisualizationLarge} + onVisualizationLargeChange={setWorkspaceVisualizationLarge} + visualizationPosition={workspaceVisualizationPosition} + onVisualizationPositionChange={setWorkspaceVisualizationPosition} + visualizationSize={workspaceVisualizationSize} + onVisualizationSizeChange={setWorkspaceVisualizationSize} sourceSettingsDefaults={{ ...(workspacePlatform?.sourceSettings ?? {}), agentUrl: workspacePlatform?.connection?.agentUrl ?? null, @@ -4870,10 +5590,12 @@ export default function App(): React.JSX.Element { weekNumber={workspaceWeek.week} courseKey={courseKey} curriculumWeek={workspaceWeek} + teamGroupId={teamWorkspaceGroupId} activeLearningStep={activeLearningStep} allowFutureLearningSteps={publicGuideCourseActive} onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined} onSubmissionRecorded={() => undefined} + onVisualizationHostChange={handleWorkspaceVisualizationHostChange} />
)} @@ -4985,7 +5707,9 @@ export default function App(): React.JSX.Element { { const target = runningPlatformCloseTarget setRunningPlatformCloseTarget(null) - void closeRunningPlatformSession(target) + void confirmRunningPlatformClose(target) }} /> )} @@ -5102,13 +5826,21 @@ export default function App(): React.JSX.Element { action={moduleShutdownConfirm.action} moduleNames={moduleShutdownConfirm.moduleNames} onKeepRunning={() => { - const { action } = moduleShutdownConfirm + const { action, platformToClose } = moduleShutdownConfirm setModuleShutdownConfirm(null) + if (platformToClose) { + void closeRunningPlatformSession(platformToClose, { stopModules: false }) + return + } handleManualSessionEnd(action, false) }} onStopModules={() => { - const { action } = moduleShutdownConfirm + const { action, platformToClose } = moduleShutdownConfirm setModuleShutdownConfirm(null) + if (platformToClose) { + void closeRunningPlatformSession(platformToClose, { stopModules: true }) + return + } handleManualSessionEnd(action, true) }} /> @@ -5154,6 +5886,40 @@ export default function App(): React.JSX.Element {
+ {workspaceVisualizationHost && (workspaceVisualizationOpen || workspaceVisualizationMinimized) && ( +
+ workspaceVisualizationHostRef.current?.onDragStart(event)} + onToggleLarge={() => workspaceVisualizationHostRef.current?.onToggleLarge()} + onMinimize={() => workspaceVisualizationHostRef.current?.onMinimize()} + onClose={() => workspaceVisualizationHostRef.current?.onClose()} + onStart={() => { + const start = workspaceVisualizationHostRef.current?.onStart + if (!start) return + void start().finally(() => { + setWorkspaceVisualizationHost((current) => current ? { ...current, busy: false } : current) + }) + }} + onTerminate={async () => { + await workspaceVisualizationHostRef.current?.onTerminate() + setWorkspaceVisualizationHost((current) => current ? { ...current, running: false, busy: false } : current) + }} + onResizeStart={(event, direction) => workspaceVisualizationHostRef.current?.onResizeStart(event, direction)} + /> +
+ )} {bootLoadingVisible && ( void onSubmit: (content: string) => void }): React.JSX.Element { @@ -279,7 +281,12 @@ export const QaInputBox = memo(function QaInputBox({ type="button" disabled={!canSubmit} onClick={busy ? onStop : submit} - className="grid w-10 shrink-0 cursor-pointer place-items-center bg-[#2563eb] text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:opacity-45 disabled:hover:bg-[#2563eb]" + className={[ + 'grid w-10 shrink-0 cursor-pointer place-items-center text-white transition disabled:cursor-default disabled:opacity-45', + accent === 'cyan' + ? 'bg-[#0e7490] hover:bg-[#0891b2] disabled:hover:bg-[#0e7490]' + : 'bg-[#2563eb] hover:bg-[#1d4ed8] disabled:hover:bg-[#2563eb]' + ].join(' ')} > {busy ? : } diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 0cf88d8..14871bc 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -30,7 +30,7 @@ export default function Sidebar({ sessionStartedAt?: string | null sessionEndedAt?: string | null sessionEndsAt?: string | null - runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }> + runningSessions?: Array<{ id: string; title: string; runningModuleCount?: number; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }> canReleaseControl?: boolean onOpenSession?: (sessionId?: string) => void onDisconnectSession?: (sessionId?: string) => void @@ -138,7 +138,7 @@ export default function Sidebar({ {sessionStatus === 'running' && ( @@ -191,7 +197,7 @@ export default function Sidebar({ setSessionPopoverOpen(false) onOpenSession?.(session.id) }} - className="flex min-h-[54px] w-full cursor-pointer items-center gap-3 py-3 pl-3 pr-11 text-left transition hover:bg-white/[0.06]" + className="flex min-h-[68px] w-full cursor-pointer items-center gap-3 py-3 pl-3 pr-11 text-left transition hover:bg-white/[0.06]" > @@ -199,10 +205,16 @@ export default function Sidebar({ {session.title} {(session.startedAt || session.endsAt) && ( - - {[session.startedAt ? `시작 ${session.startedAt}` : null, session.endsAt ? `종료 ${session.endsAt}` : null].filter(Boolean).join(' · ')} + + {[ + session.startedAt ? `시작 ${session.startedAt}` : null, + session.endsAt ? `종료 ${session.endsAt}` : null + ].filter(Boolean).join(' · ')} )} + {session.runningModuleCount !== undefined && ( + 실행 중인 모듈 {session.runningModuleCount}개 + )} {sessionStatus === 'running' && ( diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx index 8f4ec29..f70fc21 100644 --- a/src/renderer/src/components/TitleBar.tsx +++ b/src/renderer/src/components/TitleBar.tsx @@ -1,5 +1,5 @@ import icon from '/assets/icon.png' -import { Bell, BookOpen, Bug, Building2, CheckCheck, ChevronLeft, ChevronRight, HelpCircle, LogOut, Minus, Search, Square, X } from 'lucide-react' +import { Bell, BookOpen, Bug, Building2, CheckCheck, ChevronLeft, ChevronRight, Clipboard, HelpCircle, LogOut, Minus, RotateCw, ScrollText, Search, Square, Wrench, X } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Tooltip from './Tooltip' @@ -97,7 +97,13 @@ const normalizeNotification = (notification: NotificationMessage): NotificationM export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCourseSelection, transientNotice = null }: TitleBarProps): React.JSX.Element { const [notificationOpen, setNotificationOpen] = useState(false) const [accountOpen, setAccountOpen] = useState(false) + const [toolsOpen, setToolsOpen] = useState(false) const [showCloseConfirm, setShowCloseConfirm] = useState(false) + const [appLogsOpen, setAppLogsOpen] = useState(false) + const [appLogsLoading, setAppLogsLoading] = useState(false) + const [appLogContent, setAppLogContent] = useState('') + const [appLogPath, setAppLogPath] = useState('') + const [appLogsCopied, setAppLogsCopied] = useState(false) const [notifications, setNotifications] = useState([]) const [localNotifications, setLocalNotifications] = useState([]) const [notificationModalOpen, setNotificationModalOpen] = useState(false) @@ -111,6 +117,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo const [transientNoticeVisible, setTransientNoticeVisible] = useState(false) const notificationRef = useRef(null) const accountRef = useRef(null) + const toolsRef = useRef(null) const enqueuedTransientNoticeIdsRef = useRef>(new Set()) const knownNotificationIdsRef = useRef>(new Set()) const notificationHydratedRef = useRef(false) @@ -148,6 +155,32 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo const affiliationLabel = formatAffiliation(user?.primaryAffiliation) const winButton = 'grid h-7 w-7 cursor-pointer place-items-center rounded text-white/50 transition hover:bg-white/[0.06] hover:text-white' + const loadAppLogs = useCallback(async (): Promise => { + if (!window.appDiagnostics) { + setAppLogContent('현재 앱에서는 로그 조회 기능을 사용할 수 없습니다. LMS 앱을 최신 버전으로 다시 실행하세요.') + return + } + + setAppLogsLoading(true) + try { + const result = await window.appDiagnostics.read() + setAppLogContent(result.content) + setAppLogPath(result.path) + } catch (error) { + setAppLogContent(`로그를 읽지 못했습니다.\n${error instanceof Error ? error.message : String(error)}`) + } finally { + setAppLogsLoading(false) + } + }, []) + const handleCopyAppLogs = useCallback((): void => { + if (!appLogContent) return + void navigator.clipboard.writeText(appLogContent) + .then(() => { + setAppLogsCopied(true) + window.setTimeout(() => setAppLogsCopied(false), 1600) + }) + .catch((error) => console.error('[app-logs] copy failed', error)) + }, [appLogContent]) const enqueueTransientNotice = useCallback((notice: { id: number; title: string; tone?: 'info' | 'warning' | 'error' }): void => { const title = notice.title.trim() if (!title) return @@ -325,6 +358,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo setNotificationOpen(false) } if (!accountRef.current?.contains(target)) setAccountOpen(false) + if (!toolsRef.current?.contains(target)) setToolsOpen(false) } window.addEventListener('pointerdown', handlePointerDown) @@ -344,16 +378,17 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo setNotificationModalOpen(false) return } - if (notificationOpen || accountOpen) { + if (notificationOpen || accountOpen || toolsOpen) { event.preventDefault() setNotificationOpen(false) setAccountOpen(false) + setToolsOpen(false) } } window.addEventListener('keydown', handleEscape) return () => window.removeEventListener('keydown', handleEscape) - }, [accountOpen, notificationModalOpen, notificationOpen, showCloseConfirm]) + }, [accountOpen, notificationModalOpen, notificationOpen, showCloseConfirm, toolsOpen]) const handleNotificationClick = (): void => { setNotificationOpen((current) => !current) @@ -495,20 +530,60 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo - - - +
+ + + + {toolsOpen ? ( +
+ + + +
+ ) : null} +
+ +
+ {appLogsLoading ? ( +
로그를 불러오는 중입니다.
+ ) : appLogContent ? ( +
+                  {appLogContent}
+                
+ ) : ( +
+ 아직 기록된 앱 로그가 없습니다. +
+ )} +
+
+ + +
+ +
+ ) : null} + {showCloseConfirm ? (
Promise<{ opened: boolean; message?: string }> close: () => void } + appDiagnostics?: { + read: () => Promise<{ path: string; content: string }> + } appLifecycle: { quit: () => void } @@ -105,12 +127,12 @@ interface Window { }> createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }> delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> - start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> - build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> + start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }> + build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => Promise<{ ok: boolean; message: string }> stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }> stopRunning: () => Promise<{ ok: boolean; message: string }> - restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> + restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }> logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }> } @@ -137,7 +159,7 @@ interface Window { }> message?: string }> - submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => Promise<{ + submit: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => Promise<{ ok: boolean message: string commit?: { @@ -148,6 +170,21 @@ interface Window { message: string } }> + teamPublish: (moduleName: string, workspaceRoot?: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => Promise<{ + ok: boolean + message: string + commit?: { + hash: string + shortHash: string + author: string + date: string + message: string + } + }> + teamPull: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ + ok: boolean + message: string + }> restoreFile: (moduleName: string, workspaceRoot?: string, folderName?: string, filePath?: string, status?: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked') => Promise<{ ok: boolean message: string @@ -198,7 +235,7 @@ interface Window { stack: 'autoware' | 'nav2' | 'slam' | null message?: string }> - start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => Promise<{ + start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean running: boolean stack: 'autoware' | 'nav2' | 'slam' | null @@ -223,6 +260,10 @@ interface Window { read: (workspaceRoot?: string) => Promise write: (workspaceRoot: string | undefined, layout: WorldLayout) => Promise } + maps: { + readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => Promise> + sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }> + } visualization: Window['visualization'] git: Window['git'] terminal: Window['terminal'] @@ -237,6 +278,10 @@ interface Window { rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType } + maps: { + readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise> + sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }> + } modules: { list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => ReturnType install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{ @@ -257,22 +302,25 @@ interface Window { } }> createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }> - start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> + transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => Promise<{ ok: boolean; workspacePath: string }> + start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }> build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }> resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => Promise<{ ok: boolean; message: string }> - restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> + restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }> logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> } visualization: { status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }> - start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }> + start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }> stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }> } git: { status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ReturnType - submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ReturnType + submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string, repositoryUrl?: string) => ReturnType + teamPublish: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string) => ReturnType + teamPull: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType restoreFile: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, filePath?: string, status?: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked') => ReturnType submissions: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ReturnType commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ReturnType diff --git a/src/renderer/src/features/AiGuideView.tsx b/src/renderer/src/features/AiGuideView.tsx index 69865e4..b34bde3 100644 --- a/src/renderer/src/features/AiGuideView.tsx +++ b/src/renderer/src/features/AiGuideView.tsx @@ -80,38 +80,38 @@ export default function AiGuideView(props: Props): React.JSX.Element {
-
-
+
{[ { label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car }, - { label: '예상 학습 시간', value: week.duration, Icon: Clock3 }, - ...(week.detail.requiresSourceSubmission - ? [{ - label: '제출 여부', - value: week.detail.sourceSubmitted ? '제출 완료' : '미제출', - Icon: Upload, - valueClassName: week.detail.sourceSubmitted ? 'text-[#178c78]' : 'text-[#e11d48]' - }] - : []) + { label: '예상 학습 시간', value: week.duration, Icon: Clock3 } ].map(({ label, value, Icon, valueClassName }) => (
diff --git a/src/renderer/src/features/curriculum/types.ts b/src/renderer/src/features/curriculum/types.ts index 5d4b13f..d876eb6 100644 --- a/src/renderer/src/features/curriculum/types.ts +++ b/src/renderer/src/features/curriculum/types.ts @@ -52,7 +52,6 @@ export type CurriculumWeek = { references?: DownloadResource[] exampleCodes?: DownloadResource[] deliverable: string - requiresSourceSubmission: boolean sourceSubmitted?: boolean sourceSubmittedAt?: string | null } @@ -69,6 +68,7 @@ export type CurriculumWeek = { dockerImage?: string | null packageName?: string | null launchFile?: string | null + runtimeTarget?: 'LOCAL' | 'PLATFORM' | 'SHARED' | null }> environmentProfiles?: EnvironmentProfile[] maps?: Array<{ @@ -81,6 +81,7 @@ export type CurriculumWeek = { custom?: boolean layout?: unknown }> + isTeamProject?: boolean } export type CurriculumResponse = { @@ -102,6 +103,10 @@ export type CurriculumResponse = { activeWeek?: number | null activeLearningStep?: string | null learningStats?: LearningStat[] + studentGroup?: { + id: string + name: string + } | null platforms?: PlatformOption[] } diff --git a/src/renderer/src/features/help/contextHelp.ts b/src/renderer/src/features/help/contextHelp.ts index 13eee27..3b5f0bd 100644 --- a/src/renderer/src/features/help/contextHelp.ts +++ b/src/renderer/src/features/help/contextHelp.ts @@ -2,10 +2,12 @@ import type { DriveStep } from 'driver.js' export function getContextHelpSteps({ platformModalOpen, - localPcLayoutOpen + localPcLayoutOpen, + aiGuideOpen }: { platformModalOpen: boolean localPcLayoutOpen: boolean + aiGuideOpen: boolean }): DriveStep[] { if (platformModalOpen) { return [ @@ -20,6 +22,35 @@ export function getContextHelpSteps({ ] } + if (aiGuideOpen) { + return [ + { + element: '[data-help="ai-guide-context"]', + popover: { + title: '학습 맥락과 빠른 질문', + description: '현재 강좌와 주차 정보를 확인하고, 자주 묻는 질문을 바로 선택할 수 있습니다. 이전 대화도 이곳에서 다시 열 수 있습니다.', + side: 'right' + } + }, + { + element: '[data-help="ai-guide-conversation"]', + popover: { + title: 'AI 가이드 대화', + description: '학습 중 궁금한 내용을 입력하면 현재 강좌와 주차 맥락을 바탕으로 답변합니다.', + side: 'left' + } + }, + { + element: '[data-help="ai-guide-questions"]', + popover: { + title: '질문 목록', + description: '이전에 입력한 질문 목록입니다. 항목을 누르면 해당 질문과 답변 위치로 바로 이동합니다.', + side: 'left' + } + } + ] + } + if (localPcLayoutOpen) { return [ { @@ -46,6 +77,14 @@ export function getContextHelpSteps({ side: 'left' } }, + { + element: '[data-help="module-parking"]', + popover: { + title: '모듈 보관', + description: '사용하지 않는 모듈을 이 영역으로 드래그하면 전체 설치·실행·중지 대상에서 제외됩니다. 보관한 모듈은 개별적으로 제어할 수 있습니다.', + side: 'left' + } + }, { element: '[data-help="workspace-chat"]', popover: { diff --git a/src/renderer/src/features/platforms/PlatformSelectionPanel.tsx b/src/renderer/src/features/platforms/PlatformSelectionPanel.tsx index 40441d6..0675847 100644 --- a/src/renderer/src/features/platforms/PlatformSelectionPanel.tsx +++ b/src/renderer/src/features/platforms/PlatformSelectionPanel.tsx @@ -83,7 +83,8 @@ export default function PlatformSelectionPanel({ const { id, status, action, state, iconKey } = platform const Icon = PLATFORM_ICONS[iconKey] ?? Monitor const locked = state === 'locked' - const statusPending = platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(id) + const statusPending = platform.platformKey !== 'futuredrive-local-pc' + && !checkedRemotePlatformStatusIds.has(id) const unavailable = !statusPending && isPlatformUnavailable(platform) const actionDisabled = statusPending || locked || unavailable const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id) diff --git a/src/renderer/src/features/platforms/types.ts b/src/renderer/src/features/platforms/types.ts index 58b0a12..0bde18b 100644 --- a/src/renderer/src/features/platforms/types.ts +++ b/src/renderer/src/features/platforms/types.ts @@ -3,7 +3,13 @@ export type StudioSourceSettings = { giteaBaseUrl: string giteaOwner: string gpuEnabled: boolean + editorWordWrap: boolean + editorFontSize: number + editorTabSize: number + editorMinimap: boolean rosDomainId: string + rosLinkedPlatformId?: string | null + visualizationBridgeLocation?: 'local' | 'platform' agentUrl?: string | null sshUser?: string | null sshPort?: string | null @@ -18,6 +24,14 @@ export type EnvironmentProfile = { config: Record } +export type VehicleRuntimeProfile = { + profileKey: string + driveType: 'ackermann' | 'differential' + wheelbaseMeters: number + maxSpeedMetersPerSecond: number + maxSteeringAngleRadians: number +} + export type PlatformOption = { id: string platformKey: string @@ -30,6 +44,7 @@ export type PlatformOption = { state: 'ready' | 'request' | 'reserved' | 'locked' sourceSettings?: Partial | null environmentProfiles?: EnvironmentProfile[] + vehicleRuntimeProfile?: VehicleRuntimeProfile | null environmentConfigOverride?: Record | null connection?: { registered: boolean diff --git a/src/renderer/src/features/workspace/ModuleDeleteConfirm.tsx b/src/renderer/src/features/workspace/ModuleDeleteConfirm.tsx index 40f1ad6..a54e24e 100644 --- a/src/renderer/src/features/workspace/ModuleDeleteConfirm.tsx +++ b/src/renderer/src/features/workspace/ModuleDeleteConfirm.tsx @@ -4,6 +4,7 @@ import type React from 'react' type DeletableModule = { name: string + state?: 'running' | 'stopped' | 'uninstalled' } type ModuleDeleteConfirmProps = { @@ -40,9 +41,11 @@ export default function ModuleDeleteConfirm({ {module.name} 삭제할까요?

- 워크스페이스에 설치된 모듈 폴더가 삭제됩니다. + {module.state === 'uninstalled' + ? '등록된 커스텀 모듈 정보를 삭제합니다.' + : '워크스페이스에 설치된 모듈 폴더가 삭제됩니다.'}

- {allowRepositoryDeletion &&