commit 9bb4f858022f47dbced2b0c38814fae504e606a4 Author: Exterrata <38149279+Exterrata@users.noreply.github.com> Date: Sun Oct 25 02:46:35 2026 -0400 CVR Manager 1.0.0 Co-Authored-By: Claude Sonnet 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e6a2f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +dist/ +*.user +.vs/ diff --git a/App.axaml b/App.axaml new file mode 100644 index 0000000..4b9e92b --- /dev/null +++ b/App.axaml @@ -0,0 +1,164 @@ + + + + + + #5B8CFF + F0 M118.77,2.25 L123.25,2.08 L194.00,2.11 L195.00,2.01 L195.50,2.09 L196.25,2.47 L196.53,2.75 L198.03,5.50 L198.58,6.25 L200.00,8.93 L200.57,9.75 L201.00,10.70 L201.59,11.50 L202.05,12.50 L202.59,13.25 L204.00,15.92 L204.58,16.75 L205.05,17.75 L205.60,18.50 L206.00,19.39 L206.60,20.25 L208.00,22.89 L208.60,23.75 L209.00,24.65 L209.62,25.50 L210.09,26.50 L210.62,27.25 L212.00,29.87 L212.61,30.75 L213.00,31.62 L213.63,32.50 L214.10,33.50 L214.63,34.25 L216.00,36.85 L216.62,37.75 L217.00,38.60 L217.65,39.50 L218.00,40.30 L218.64,41.25 L220.00,43.83 L220.65,44.75 L221.00,45.56 L222.66,48.25 L224.00,50.81 L224.66,51.75 L225.00,52.54 L226.67,55.25 L228.00,57.78 L229.69,60.50 L231.00,63.00 L232.69,65.75 L233.16,66.75 L233.70,67.50 L235.01,70.00 L235.68,71.00 L236.16,72.00 L236.71,72.75 L237.04,73.50 L237.71,74.50 L239.04,77.00 L239.80,78.25 L240.25,78.58 L240.50,78.55 L240.80,78.25 L241.19,77.25 L242.61,75.00 L243.08,74.00 L243.61,73.25 L244.07,72.25 L244.59,71.50 L245.05,70.50 L245.74,69.50 L246.07,68.75 L246.75,67.78 L248.08,65.25 L248.61,64.50 L249.07,63.50 L249.62,62.75 L250.09,61.75 L250.75,60.81 L252.08,58.25 L253.75,55.56 L254.11,54.75 L254.75,53.83 L256.11,51.25 L256.63,50.50 L257.10,49.50 L257.65,48.75 L258.13,47.75 L258.75,46.85 L261.12,42.50 L261.67,41.75 L262.14,40.75 L262.67,40.00 L265.13,35.50 L266.70,33.00 L267.16,32.00 L267.66,31.25 L268.15,30.25 L268.69,29.50 L269.15,28.50 L269.70,27.75 L270.17,26.75 L270.71,26.00 L272.15,23.25 L272.70,22.50 L273.17,21.50 L273.73,20.75 L274.19,19.75 L274.72,19.00 L276.17,16.25 L276.71,15.50 L277.18,14.50 L277.74,13.75 L280.18,9.25 L280.74,8.50 L281.20,7.50 L281.75,6.75 L282.21,5.75 L282.74,5.00 L283.19,4.00 L284.16,2.50 L284.75,2.15 L285.50,2.04 L394.50,2.08 L400.00,2.29 L405.50,2.71 L412.25,3.74 L413.75,4.14 L416.92,4.75 L420.73,5.75 L426.89,7.75 L428.00,8.28 L429.50,8.74 L430.50,9.26 L431.88,9.75 L432.75,10.25 L434.13,10.75 L435.00,11.31 L436.20,11.75 L437.00,12.28 L438.15,12.75 L439.00,13.34 L440.00,13.77 L440.75,14.33 L441.75,14.77 L442.50,15.34 L443.37,15.75 L444.00,16.25 L445.00,16.79 L445.53,17.25 L446.50,17.77 L450.75,20.79 L455.75,24.81 L459.00,27.76 L462.07,30.75 L466.00,34.92 L469.00,38.50 L472.00,42.38 L475.02,46.75 L477.00,49.93 L477.39,50.75 L478.00,51.59 L478.50,52.70 L479.06,53.50 L479.48,54.50 L480.00,55.25 L480.50,56.50 L481.10,57.50 L481.46,58.50 L482.04,59.50 L482.45,60.75 L483.00,61.75 L483.40,63.00 L484.01,64.25 L484.44,65.75 L485.03,67.00 L485.49,68.75 L486.00,69.97 L486.41,71.75 L487.08,73.75 L487.46,75.75 L488.05,77.75 L488.40,80.00 L489.00,82.78 L489.58,88.00 L489.84,89.50 L490.10,93.00 L490.17,102.50 L490.06,106.25 L489.82,108.75 L489.00,115.38 L488.47,117.75 L488.03,120.50 L487.45,122.50 L487.07,124.50 L485.00,131.22 L484.50,132.29 L484.00,133.96 L483.46,135.00 L483.00,136.39 L482.43,137.50 L481.98,138.75 L481.40,139.75 L481.00,140.85 L480.44,141.75 L480.00,142.86 L479.41,143.75 L479.00,144.73 L478.47,145.50 L478.02,146.50 L477.50,147.25 L477.01,148.25 L476.44,149.00 L474.98,151.50 L473.50,153.53 L473.00,154.40 L469.00,159.67 L464.82,164.50 L460.77,168.75 L456.25,172.87 L452.84,175.75 L448.75,178.83 L447.68,179.50 L447.16,180.00 L446.91,180.50 L447.05,181.00 L447.98,182.50 L448.43,183.50 L448.97,184.25 L449.42,185.25 L450.00,186.03 L450.44,187.00 L451.00,187.78 L451.44,188.75 L451.96,189.50 L452.42,190.50 L452.96,191.25 L453.42,192.25 L454.00,193.05 L454.42,194.00 L455.00,194.81 L456.40,197.50 L457.00,198.36 L457.40,199.25 L459.00,201.82 L460.40,204.50 L461.00,205.39 L461.38,206.25 L463.00,208.85 L463.50,209.93 L465.00,212.44 L465.37,213.25 L466.00,214.14 L466.50,215.22 L467.00,215.89 L469.35,220.25 L470.00,221.18 L470.50,222.25 L471.04,223.00 L471.50,223.99 L472.00,224.71 L473.47,227.50 L474.00,228.22 L474.48,229.25 L475.03,230.00 L475.48,231.00 L476.00,231.75 L477.46,234.50 L478.00,235.23 L478.46,236.25 L479.00,236.98 L479.47,238.00 L480.01,238.75 L481.44,241.50 L482.00,242.28 L482.43,243.25 L483.00,244.03 L483.50,245.10 L484.13,246.00 L484.70,247.25 L484.63,247.75 L484.00,247.93 L396.50,247.91 L395.50,247.14 L394.90,246.00 L394.34,245.25 L393.88,244.25 L393.32,243.50 L392.86,242.50 L392.33,241.75 L391.88,240.75 L391.33,240.00 L390.87,239.00 L390.32,238.25 L389.86,237.25 L389.30,236.50 L388.84,235.50 L388.32,234.75 L387.86,233.75 L387.32,233.00 L386.85,232.00 L386.29,231.25 L385.83,230.25 L385.28,229.50 L383.84,226.75 L383.29,226.00 L382.82,225.00 L382.26,224.25 L381.79,223.25 L381.25,222.50 L379.81,219.75 L379.26,219.00 L378.79,218.00 L378.25,217.27 L377.77,216.25 L377.25,215.52 L375.93,213.00 L375.25,212.03 L374.91,211.25 L374.25,210.32 L372.90,207.75 L372.25,206.79 L371.90,206.00 L371.25,205.07 L370.75,204.02 L370.25,203.35 L368.89,200.75 L368.25,199.83 L367.88,199.00 L367.25,198.12 L366.75,197.11 L366.00,196.24 L365.25,196.02 L311.75,196.05 L310.75,196.13 L310.25,196.06 L309.86,195.75 L309.81,195.50 L309.97,195.00 L310.71,194.00 L311.15,193.00 L311.75,192.17 L312.17,191.25 L312.75,190.43 L313.25,189.34 L314.75,186.84 L315.12,186.00 L316.82,183.25 L317.25,182.29 L317.75,181.57 L319.23,178.75 L319.75,178.06 L320.23,177.00 L320.75,176.30 L321.23,175.25 L321.77,174.50 L323.20,171.75 L323.75,170.99 L324.20,170.00 L324.76,169.25 L325.20,168.25 L325.75,167.48 L326.19,166.50 L326.75,165.69 L327.25,164.61 L327.75,163.92 L328.16,163.00 L328.75,162.20 L329.25,161.12 L329.75,160.45 L331.25,157.55 L331.75,156.87 L333.25,154.05 L333.83,153.25 L335.24,150.50 L335.78,149.75 L336.23,148.75 L336.79,148.00 L337.24,147.00 L337.79,146.25 L339.22,143.50 L339.75,142.75 L340.20,141.75 L340.75,141.00 L341.25,139.92 L341.75,139.25 L342.25,138.18 L342.75,137.48 L344.25,134.63 L344.86,133.75 L345.25,132.85 L345.86,132.00 L348.25,127.56 L348.75,126.89 L349.25,125.82 L349.75,125.15 L350.25,124.07 L350.82,123.25 L352.25,120.50 L353.50,118.63 L354.25,118.26 L354.75,118.19 L355.75,118.27 L393.25,118.36 L397.00,118.06 L399.25,117.57 L401.50,116.68 L403.25,115.65 L404.75,114.58 L405.98,113.50 L408.00,111.51 L409.89,109.25 L410.96,107.50 L411.88,105.50 L412.46,103.25 L412.87,101.00 L412.91,98.00 L412.76,96.25 L411.94,92.75 L411.00,90.66 L409.94,89.00 L408.98,87.75 L407.75,86.41 L405.22,84.00 L403.96,83.00 L403.07,82.50 L402.44,82.00 L400.25,80.92 L397.25,80.08 L396.25,79.91 L394.00,79.77 L337.50,79.79 L336.75,79.92 L336.30,80.25 L335.77,81.25 L335.23,82.00 L334.76,83.00 L334.21,83.75 L333.75,84.75 L333.20,85.50 L332.75,86.47 L332.21,87.25 L331.76,88.25 L331.21,89.00 L330.75,90.00 L330.19,90.75 L329.75,91.73 L329.19,92.50 L327.75,95.24 L327.20,96.00 L326.75,96.98 L326.25,97.63 L325.75,98.67 L325.25,99.36 L323.75,102.22 L323.25,102.88 L322.75,103.93 L322.16,104.75 L321.75,105.65 L320.25,108.12 L319.75,109.20 L319.25,109.86 L318.75,110.91 L318.15,111.75 L317.75,112.63 L317.25,113.32 L315.83,116.00 L315.15,117.00 L314.75,117.89 L314.13,118.75 L311.75,123.13 L311.25,123.79 L310.75,124.86 L310.12,125.75 L307.81,130.00 L306.24,132.50 L303.79,137.00 L303.23,137.75 L302.75,138.78 L302.22,139.50 L299.77,144.00 L299.22,144.75 L298.75,145.75 L298.21,146.50 L296.75,149.27 L296.22,150.00 L295.75,151.01 L295.20,151.75 L294.74,152.75 L294.19,153.50 L292.75,156.24 L292.20,157.00 L291.74,158.00 L291.17,158.75 L290.75,159.69 L290.18,160.50 L288.75,163.22 L288.18,164.00 L287.72,165.00 L287.16,165.75 L286.71,166.75 L286.17,167.50 L284.75,170.21 L284.16,171.00 L283.70,172.00 L283.15,172.75 L281.70,175.50 L281.17,176.25 L280.75,177.19 L280.15,178.00 L279.69,179.00 L279.14,179.75 L278.68,180.75 L278.14,181.50 L277.69,182.50 L276.13,185.00 L275.67,186.00 L275.12,186.75 L273.69,189.50 L273.14,190.25 L272.75,191.11 L271.11,193.75 L269.75,196.35 L269.12,197.25 L268.65,198.25 L268.10,199.00 L265.75,203.32 L264.08,206.00 L261.75,210.30 L261.09,211.25 L260.75,212.01 L260.06,213.00 L257.75,217.26 L256.05,220.00 L253.75,224.25 L252.03,227.00 L250.72,229.50 L250.05,230.50 L249.73,231.25 L248.02,234.00 L246.71,236.50 L245.01,239.25 L244.69,240.00 L244.15,240.75 L242.70,243.50 L241.15,246.00 L240.75,247.12 L240.50,247.35 L240.25,247.38 L239.81,247.00 L237.62,243.00 L237.07,242.25 L236.61,241.25 L236.06,240.50 L234.62,237.75 L234.08,237.00 L233.61,236.00 L233.05,235.25 L232.59,234.25 L232.05,233.50 L230.61,230.75 L230.06,230.00 L229.60,229.00 L229.04,228.25 L228.57,227.25 L227.05,224.75 L226.59,223.75 L226.04,223.00 L225.57,222.00 L224.01,219.50 L222.57,216.75 L221.01,214.25 L220.54,213.25 L220.01,212.50 L218.55,209.75 L218.00,209.00 L217.54,208.00 L217.00,207.28 L214.68,203.00 L213.00,200.32 L211.66,197.75 L211.00,196.77 L210.67,196.00 L209.00,193.33 L208.50,192.28 L207.00,189.79 L206.64,189.00 L206.00,188.09 L205.50,187.04 L205.00,186.35 L203.64,183.75 L203.00,182.82 L202.63,182.00 L202.00,181.12 L201.61,180.25 L201.00,179.36 L199.61,176.75 L199.00,175.87 L198.61,175.00 L198.06,174.25 L197.59,173.25 L197.00,172.41 L195.61,169.75 L195.00,168.89 L194.60,168.00 L194.00,167.19 L193.58,166.25 L193.00,165.42 L191.60,162.75 L191.00,161.92 L190.58,161.00 L190.00,160.22 L189.56,159.25 L189.00,158.44 L187.59,155.75 L187.02,155.00 L186.57,154.00 L186.00,153.23 L184.56,150.50 L184.00,149.69 L183.57,148.75 L183.01,148.00 L182.55,147.00 L181.99,146.25 L180.55,143.50 L180.00,142.74 L179.55,141.75 L178.99,141.00 L178.53,140.00 L177.98,139.25 L176.55,136.50 L176.00,135.75 L175.53,134.75 L174.97,134.00 L174.51,133.00 L173.97,132.25 L172.52,129.50 L172.00,128.79 L171.52,127.75 L170.96,127.00 L170.50,126.02 L170.00,125.33 L169.50,124.25 L169.00,123.54 L168.51,122.50 L167.96,121.75 L167.50,120.78 L166.93,120.00 L166.50,119.04 L165.94,118.25 L164.50,115.51 L163.94,114.75 L163.50,113.80 L162.92,113.00 L162.50,112.08 L161.00,109.61 L160.50,108.56 L160.00,107.89 L159.50,106.86 L158.00,104.91 L157.50,103.96 L155.50,101.12 L152.53,97.75 L150.69,96.00 L148.34,94.00 L146.95,93.00 L146.05,92.50 L145.47,92.00 L144.50,91.53 L143.75,90.97 L142.63,90.50 L141.90,90.00 L140.54,89.50 L139.72,89.00 L138.25,88.55 L137.00,87.99 L134.93,87.50 L133.50,87.03 L127.75,86.16 L125.00,86.06 L122.00,86.16 L116.25,87.00 L114.72,87.50 L112.75,87.96 L111.55,88.50 L110.00,88.98 L109.11,89.50 L107.75,89.98 L106.99,90.50 L106.00,90.91 L105.21,91.50 L104.25,91.94 L103.50,92.55 L102.69,93.00 L100.00,95.03 L98.91,96.00 L95.92,99.00 L93.86,101.50 L91.91,104.25 L89.85,108.00 L87.90,112.75 L86.89,116.50 L86.09,122.00 L85.97,124.50 L86.04,127.50 L86.90,133.50 L87.45,135.25 L87.85,137.00 L88.88,139.75 L89.42,140.75 L89.87,142.00 L90.50,142.98 L90.80,143.75 L92.82,147.00 L94.91,149.75 L97.75,152.81 L101.00,155.68 L104.00,157.82 L104.88,158.25 L105.54,158.75 L106.68,159.25 L107.50,159.81 L108.71,160.25 L109.51,160.75 L111.00,161.21 L112.10,161.75 L114.00,162.23 L115.25,162.69 L121.25,163.73 L125.00,163.89 L129.00,163.67 L134.25,162.73 L135.75,162.19 L137.50,161.77 L138.75,161.16 L140.12,160.75 L141.00,160.22 L142.25,159.75 L144.25,158.54 L144.75,158.54 L145.25,158.85 L146.00,160.25 L146.50,160.88 L147.00,161.88 L147.50,162.56 L147.94,163.50 L148.50,164.27 L148.98,165.25 L149.50,165.92 L150.00,166.93 L150.50,167.59 L151.95,170.25 L152.50,170.97 L153.00,171.99 L153.56,172.75 L154.92,175.25 L156.54,177.75 L158.00,180.42 L159.52,182.75 L162.00,187.13 L162.50,187.77 L164.00,190.49 L165.50,192.81 L166.98,195.50 L167.53,196.25 L168.00,197.21 L168.50,197.84 L169.00,198.86 L169.50,199.52 L171.00,202.25 L171.57,203.00 L172.00,203.90 L172.50,204.57 L173.97,207.25 L174.54,208.00 L175.00,208.95 L175.59,209.75 L176.00,210.66 L176.50,211.31 L177.00,212.33 L177.52,213.00 L178.01,214.00 L178.57,214.75 L179.00,215.70 L179.50,216.37 L180.00,217.39 L180.50,218.02 L181.00,219.03 L181.54,219.75 L183.00,222.43 L183.50,223.07 L184.00,224.08 L184.51,224.75 L186.00,227.48 L186.50,228.13 L186.96,229.00 L187.50,229.65 L187.60,230.00 L187.52,230.50 L187.04,231.00 L186.00,231.46 L185.25,232.02 L184.25,232.44 L183.48,233.00 L182.50,233.39 L181.63,234.00 L180.50,234.45 L179.69,235.00 L178.50,235.44 L177.65,236.00 L176.50,236.40 L175.48,237.00 L174.06,237.50 L173.23,238.00 L171.75,238.48 L170.75,239.04 L169.50,239.40 L168.25,240.00 L166.58,240.50 L165.50,241.01 L163.75,241.49 L162.50,242.02 L160.57,242.50 L159.31,243.00 L157.25,243.47 L155.68,244.00 L153.25,244.48 L151.50,245.00 L148.50,245.52 L146.75,245.96 L142.75,246.48 L139.91,247.00 L129.50,247.81 L121.00,247.84 L111.00,247.13 L103.10,246.00 L101.10,245.50 L98.10,245.00 L96.25,244.45 L94.25,244.07 L92.49,243.50 L90.31,243.00 L89.00,242.48 L87.08,242.00 L85.90,241.50 L84.11,241.00 L83.00,240.47 L81.50,240.06 L80.25,239.45 L78.80,239.00 L77.75,238.43 L76.50,238.04 L75.50,237.47 L74.25,237.04 L73.25,236.46 L72.00,236.00 L71.25,235.50 L69.96,235.00 L69.24,234.50 L68.00,234.00 L67.25,233.46 L66.13,233.00 L65.25,232.39 L64.35,232.00 L63.50,231.39 L62.62,231.00 L61.75,230.36 L61.00,230.01 L60.25,229.44 L59.35,229.00 L57.00,227.35 L56.25,226.96 L52.00,223.96 L48.00,220.86 L44.50,217.93 L41.00,214.76 L36.88,210.75 L33.86,207.50 L29.75,202.76 L27.75,200.24 L24.75,196.22 L23.32,194.00 L22.75,193.32 L21.35,191.00 L20.75,190.25 L19.35,187.75 L18.75,186.92 L18.33,186.00 L17.75,185.21 L17.32,184.25 L16.75,183.40 L16.26,182.25 L15.75,181.49 L15.25,180.32 L14.74,179.50 L14.25,178.31 L13.66,177.25 L13.25,176.15 L12.73,175.25 L12.30,174.00 L11.75,173.01 L11.35,171.75 L10.73,170.50 L10.25,169.00 L9.75,167.97 L9.25,166.30 L8.69,165.00 L6.75,158.82 L5.67,154.75 L4.70,150.50 L3.71,145.25 L2.75,138.29 L2.28,131.50 L2.12,123.50 L2.30,118.00 L2.71,112.00 L3.69,104.75 L4.68,99.50 L5.71,95.00 L6.75,91.10 L7.25,89.70 L7.75,87.78 L8.25,86.53 L8.75,84.76 L9.25,83.65 L9.75,81.97 L10.25,80.93 L10.70,79.50 L11.25,78.39 L11.75,76.95 L12.27,76.00 L12.75,74.62 L13.28,73.75 L13.72,72.50 L14.25,71.64 L14.75,70.39 L15.25,69.62 L15.70,68.50 L16.25,67.69 L16.75,66.53 L17.30,65.75 L17.74,64.75 L18.30,64.00 L18.75,63.01 L19.29,62.25 L19.75,61.30 L20.25,60.65 L20.72,59.75 L21.30,59.00 L21.75,58.12 L22.25,57.50 L22.75,56.61 L27.75,49.70 L29.88,47.00 L33.75,42.53 L38.00,38.09 L41.45,34.75 L45.94,30.75 L48.41,28.75 L50.25,27.40 L50.95,26.75 L55.00,23.81 L57.25,22.41 L58.00,21.79 L59.00,21.26 L59.64,20.75 L60.50,20.33 L61.25,19.75 L62.25,19.27 L62.93,18.75 L63.97,18.25 L64.65,17.75 L65.75,17.27 L66.50,16.74 L67.50,16.33 L68.34,15.75 L69.50,15.29 L70.31,14.75 L71.50,14.29 L72.37,13.75 L73.50,13.36 L74.54,12.75 L75.75,12.34 L76.83,11.75 L78.25,11.28 L79.26,10.75 L80.75,10.28 L81.85,9.75 L83.49,9.25 L84.61,8.75 L86.44,8.25 L87.67,7.75 L89.50,7.27 L90.97,6.75 L99.00,4.75 L104.25,3.74 L111.36,2.75 Z + #7AA2FF + #9AB8FF + #B9CCFF + #4670D6 + #3858B0 + #2B4488 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 index 0000000..68d5c50 --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,18 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using CVRManager.Views; + +namespace CVRManager; + +public partial class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/Assets/app-icon.png b/Assets/app-icon.png new file mode 100644 index 0000000..6481b01 Binary files /dev/null and b/Assets/app-icon.png differ diff --git a/Assets/app.ico b/Assets/app.ico new file mode 100644 index 0000000..208a932 Binary files /dev/null and b/Assets/app.ico differ diff --git a/CVRManager.csproj b/CVRManager.csproj new file mode 100644 index 0000000..b0b9bf8 --- /dev/null +++ b/CVRManager.csproj @@ -0,0 +1,31 @@ + + + WinExe + net9.0 + enable + enable + app.manifest + false + CVRManager + Assets/app.ico + $(NoWarn);CA1416 + 1.0.0 + + + + + + + + + + + + + + + + + + + diff --git a/CacheGuard/0Harmony.dll b/CacheGuard/0Harmony.dll new file mode 100644 index 0000000..eddef54 Binary files /dev/null and b/CacheGuard/0Harmony.dll differ diff --git a/CacheGuard/CVRCacheGuard.dll b/CacheGuard/CVRCacheGuard.dll new file mode 100644 index 0000000..1d73153 Binary files /dev/null and b/CacheGuard/CVRCacheGuard.dll differ diff --git a/CacheGuard/Mono.Cecil.Mdb.dll b/CacheGuard/Mono.Cecil.Mdb.dll new file mode 100644 index 0000000..bddf5cb Binary files /dev/null and b/CacheGuard/Mono.Cecil.Mdb.dll differ diff --git a/CacheGuard/Mono.Cecil.Pdb.dll b/CacheGuard/Mono.Cecil.Pdb.dll new file mode 100644 index 0000000..9227f6d Binary files /dev/null and b/CacheGuard/Mono.Cecil.Pdb.dll differ diff --git a/CacheGuard/Mono.Cecil.Rocks.dll b/CacheGuard/Mono.Cecil.Rocks.dll new file mode 100644 index 0000000..a77ac87 Binary files /dev/null and b/CacheGuard/Mono.Cecil.Rocks.dll differ diff --git a/CacheGuard/Mono.Cecil.dll b/CacheGuard/Mono.Cecil.dll new file mode 100644 index 0000000..18735f9 Binary files /dev/null and b/CacheGuard/Mono.Cecil.dll differ diff --git a/CacheGuard/MonoMod.RuntimeDetour.dll b/CacheGuard/MonoMod.RuntimeDetour.dll new file mode 100644 index 0000000..07bb181 Binary files /dev/null and b/CacheGuard/MonoMod.RuntimeDetour.dll differ diff --git a/CacheGuard/MonoMod.Utils.dll b/CacheGuard/MonoMod.Utils.dll new file mode 100644 index 0000000..511f56f Binary files /dev/null and b/CacheGuard/MonoMod.Utils.dll differ diff --git a/Models/Models.cs b/Models/Models.cs new file mode 100644 index 0000000..018cc30 --- /dev/null +++ b/Models/Models.cs @@ -0,0 +1,195 @@ +namespace CVRManager.Models; + +public class AppConfig +{ + public string LibraryRoot { get; set; } = ""; + public string SteamUser { get; set; } = ""; + public string SteamToken { get; set; } = ""; + public string GitHubToken { get; set; } = ""; + public string Theme { get; set; } = "Default"; + public bool CacheGuard { get; set; } = true; + public bool CheckUpdates { get; set; } = true; + public List Installs { get; set; } = new(); + public List Mods { get; set; } = new(); + public List Unlocked { get; set; } = new(); + public List Hooks { get; set; } = new(); + public List Accounts { get; set; } = new(); + public List Sources { get; set; } = new(); + public List SeededSources { get; set; } = new(); +} + +public class GameAccount +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string Name { get; set; } = ""; + public string Username { get; set; } = ""; + public string UserId { get; set; } = ""; + public string AccessKey { get; set; } = ""; +} + +public record AccountChoice(string Id, string Name); + +public class SourceDef +{ + public const string CvrmgId = "cvrmg"; + + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string Name { get; set; } = ""; + public string Kind { get; set; } = "github"; + public string Repo { get; set; } = ""; + public bool Builtin { get; set; } +} + +public class HookDef +{ + public const string BeforeUpdateEvent = "BeforeUpdate"; + public const string AfterUpdateEvent = "AfterUpdate"; + public const string BeforeLaunchEvent = "BeforeLaunch"; + + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string Name { get; set; } = "New hook"; + public string Command { get; set; } = ""; + public string Arguments { get; set; } = ""; + public bool BeforeUpdate { get; set; } + public bool AfterUpdate { get; set; } = true; + public bool BeforeLaunch { get; set; } + public bool DefaultOn { get; set; } = true; + + public bool Runs(string evt) => evt switch + { + BeforeUpdateEvent => BeforeUpdate, + AfterUpdateEvent => AfterUpdate, + BeforeLaunchEvent => BeforeLaunch, + _ => false, + }; +} + +public class UnlockedBranch +{ + public string Name { get; set; } = ""; + public string Code { get; set; } = ""; + public string Key { get; set; } = ""; +} + +public class InstallInfo +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string Name { get; set; } = ""; + public string Folder { get; set; } = ""; + public string Branch { get; set; } = "public"; + public string BranchPassword { get; set; } = ""; + public string LaunchArgs { get; set; } = ""; + public string LaunchMode { get; set; } = "desktop"; + public string AccountId { get; set; } = ""; + public List Mods { get; set; } = new(); + public List HookIds { get; set; } = new(); + public Dictionary> Deployed { get; set; } = new(); +} + +public class ModAssignment +{ + public string ModId { get; set; } = ""; + public string Version { get; set; } = "latest"; + public bool Enabled { get; set; } = true; +} + +public class ModDef +{ + public const string MelonLoaderId = "melonloader"; + + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + public string Name { get; set; } = ""; + public string Repo { get; set; } = ""; + public string Type { get; set; } = "Mods"; + public string AssetFilter { get; set; } = ""; + public bool Local { get; set; } + public string Source { get; set; } = "github"; + public int CvrmgId { get; set; } + public string FileName { get; set; } = ""; + public string Author { get; set; } = ""; + public string Description { get; set; } = ""; + public string LatestVersion { get; set; } = ""; + public string Category { get; set; } = ""; + public string Status { get; set; } = ""; + public string SourceLink { get; set; } = ""; + public List Releases { get; set; } = new(); + + public void SetReleases(List fresh) + { + var keep = Releases.Where(r => fresh.All(f => f.Tag != r.Tag) && (r.Snapshot || Source == "cvrmg")).ToList(); + Releases = fresh.Concat(keep).ToList(); + } + + public string? LatestTag => + (Releases.FirstOrDefault(r => !r.Prerelease) ?? Releases.FirstOrDefault())?.Tag; +} + +public class ReleaseInfo +{ + public string Tag { get; set; } = ""; + public bool Prerelease { get; set; } + public bool Snapshot { get; set; } + public string GameVersion { get; set; } = ""; + public string LoaderVersion { get; set; } = ""; + public DateTime Published { get; set; } + public List Assets { get; set; } = new(); +} + +public class AssetInfo +{ + public string Name { get; set; } = ""; + public string Url { get; set; } = ""; + public long Size { get; set; } + public string Hash { get; set; } = ""; +} + +public record BranchInfo(string Name, string BuildId, bool PasswordRequired, string Description); + +public record BranchChoice(string Name, string Code) +{ + public string Display => Name; +} + +public record LaunchArgOption(string Flag, string Description, bool TakesValue) +{ + public string Insert => TakesValue ? Flag[..(Flag.IndexOf('=') + 1)] : Flag; + + public static IReadOnlyList All { get; } = new[] + { + new LaunchArgOption("-vr", "Launch in VR.", false), + new LaunchArgOption("--force-openxr", "Force OpenXR for VR sessions.", false), + new LaunchArgOption("--no-steam", "Skip Steam API initialization.", false), + new LaunchArgOption("--safe-mode", "Load the default Hub world and avatar, and disable OSC.", false), + new LaunchArgOption("--profile=", "Profile used to save and load the autologin file.", true), + new LaunchArgOption("--affinity=", "CPU cores the game may run on, as a hexadecimal mask.", true), + new LaunchArgOption("--content-cache-path=", "Use a different directory for the content cache.", true), + new LaunchArgOption("--allow-aggressive-memory-clearing", "Reclaim unused memory when content unloads.", false), + new LaunchArgOption("--disable-videoplayers", "Disable video players completely.", false), + new LaunchArgOption("--disable-vphd", "Turn off video player hardware decoding.", false), + new LaunchArgOption("--enable-libvlc", "Use LibVLC instead of AVPro in video players.", false), + new LaunchArgOption("--osc-listener-port=", "UDP port that receives OSC messages.", true), + new LaunchArgOption("--osc-sender-port=", "UDP port OSC messages are sent to.", true), + new LaunchArgOption("--osc-sender-ip=", "Target address for sent OSC messages.", true), + new LaunchArgOption("--osc-query-prefix=", "Prefix for OSC query parameters.", true), + new LaunchArgOption("--melonloader.hideconsole", "MelonLoader: hide the console window.", false), + new LaunchArgOption("-screen-fullscreen 0", "Unity: start windowed.", false), + new LaunchArgOption("-popupwindow", "Unity: borderless window.", false), + new LaunchArgOption("-screen-width 1280", "Unity: window width.", false), + new LaunchArgOption("-screen-height 720", "Unity: window height.", false), + new LaunchArgOption("-monitor 2", "Unity: open on this display (1 is the primary).", false), + new LaunchArgOption("-force-d3d11", "Unity: force the Direct3D 11 renderer.", false), + new LaunchArgOption("-force-vulkan", "Unity: force the Vulkan renderer.", false), + }; +} + +public record LaunchModeOption(string Key, string Label, string Arguments) +{ + public static IReadOnlyList All { get; } = new[] + { + new LaunchModeOption("desktop", "Desktop", ""), + new LaunchModeOption("vr", "VR", "-vr"), + new LaunchModeOption("safe", "Safe mode", "--safe-mode"), + }; + + public static LaunchModeOption Find(string key) => All.FirstOrDefault(m => m.Key == key) ?? All[0]; +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..f45863f --- /dev/null +++ b/Program.cs @@ -0,0 +1,16 @@ +using Avalonia; + +namespace CVRManager; + +class Program +{ + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/Services/AccountService.cs b/Services/AccountService.cs new file mode 100644 index 0000000..da20077 --- /dev/null +++ b/Services/AccountService.cs @@ -0,0 +1,58 @@ +using System.Xml; +using System.Xml.Linq; +using CVRManager.Models; + +namespace CVRManager.Services; + +public static class AccountService +{ + const string ProfilePrefix = "cvrm-"; + + public record Found(string Name, string Username, string UserId, string AccessKey); + + public static string ProfileName(GameAccount a) => ProfilePrefix + a.Id; + + static string ProfilePath(string gameDir, GameAccount a) => Path.Combine(gameDir, "ChilloutVR_Data", $"autologin-{ProfileName(a)}.profile"); + + public static string WriteProfile(string gameDir, GameAccount a) + { + var key = TokenVault.Unprotect(a.AccessKey); + if (key.Length == 0) throw new InvalidOperationException($"The access key for {a.Name} could not be read; add the account again."); + var doc = new XElement("LoginProfile", + new XElement("UserId", a.UserId), + new XElement("Username", a.Username), + new XElement("AccessKey", key)); + File.WriteAllText(ProfilePath(gameDir, a), doc.ToString()); + return ProfileName(a); + } + + public static IEnumerable Scan(IEnumerable gameDirs) + { + foreach (var dir in gameDirs) + { + var data = Path.Combine(dir, "ChilloutVR_Data"); + if (!Directory.Exists(data)) continue; + foreach (var file in Directory.EnumerateFiles(data, "autologin*.profile")) + { + if (Path.GetFileName(file).StartsWith("autologin-" + ProfilePrefix, StringComparison.OrdinalIgnoreCase)) continue; + if (Read(file) is { } found) yield return found; + } + } + } + + static Found? Read(string path) + { + try + { + var root = XDocument.Load(path).Root; + if (root == null || root.Name != "LoginProfile") return null; + string Value(string name) => root.Element(name)?.Value.Trim() ?? ""; + var username = Value("Username"); + var key = Value("AccessKey"); + if (username.Length == 0 || key.Length == 0) return null; + var stem = Path.GetFileNameWithoutExtension(path)["autologin".Length..].TrimStart('_', '-'); + return new Found(stem.Length > 0 ? stem : username, username, Value("UserId"), key); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or XmlException) { return null; } + } +} diff --git a/Services/AppManifest.cs b/Services/AppManifest.cs new file mode 100644 index 0000000..0ab01a3 --- /dev/null +++ b/Services/AppManifest.cs @@ -0,0 +1,58 @@ +using System.Text; +using CVRManager.Models; + +namespace CVRManager.Services; + +public static class AppManifest +{ + public static string LocalBuildId(InstallInfo i) + { + try + { + var path = Paths.Manifest(i); + if (!File.Exists(path)) return ""; + return Vdf.ParseDocument(File.ReadAllText(path)).Dict("AppState")?.Str("buildid") ?? ""; + } + catch { return ""; } + } + + public static string LocalBranch(InstallInfo i) + { + try + { + var path = Paths.Manifest(i); + if (!File.Exists(path)) return ""; + var key = Vdf.ParseDocument(File.ReadAllText(path)).Dict("AppState")?.Dict("UserConfig")?.Str("BetaKey") ?? ""; + return key.Length == 0 ? "public" : key; + } + catch { return ""; } + } + + public static void Write(string gameDir, string buildId, string branch) + { + var dir = Path.Combine(gameDir, "steamapps"); + Directory.CreateDirectory(dir); + var sb = new StringBuilder(); + sb.AppendLine("\"AppState\""); + sb.AppendLine("{"); + sb.AppendLine($"\t\"appid\"\t\t\"{Paths.AppId}\""); + sb.AppendLine("\t\"Universe\"\t\t\"1\""); + sb.AppendLine("\t\"name\"\t\t\"ChilloutVR\""); + sb.AppendLine("\t\"StateFlags\"\t\t\"4\""); + sb.AppendLine($"\t\"installdir\"\t\t\"{Path.GetFileName(gameDir)}\""); + sb.AppendLine($"\t\"buildid\"\t\t\"{buildId}\""); + sb.AppendLine("\t\"UserConfig\""); + sb.AppendLine("\t{"); + sb.AppendLine("\t\t\"language\"\t\t\"english\""); + sb.AppendLine($"\t\t\"BetaKey\"\t\t\"{branch}\""); + sb.AppendLine("\t}"); + sb.AppendLine("\t\"MountedConfig\""); + sb.AppendLine("\t{"); + sb.AppendLine($"\t\t\"BetaKey\"\t\t\"{branch}\""); + sb.AppendLine("\t}"); + sb.AppendLine("}"); + var file = Path.Combine(dir, $"appmanifest_{Paths.AppId}.acf"); + File.WriteAllText(file + ".tmp", sb.ToString()); + File.Move(file + ".tmp", file, true); + } +} diff --git a/Services/AssemblyReader.cs b/Services/AssemblyReader.cs new file mode 100644 index 0000000..1108499 --- /dev/null +++ b/Services/AssemblyReader.cs @@ -0,0 +1,70 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; + +namespace CVRManager.Services; + +public record AssemblyDetails(string FileName, string AssemblyName, string Version, string MelonName, string MelonVersion, string MelonAuthor, string MelonLink, string Sha256); + +public static class AssemblyReader +{ + public static AssemblyDetails Read(string path) + { + var file = Path.GetFileName(path); + var bytes = File.ReadAllBytes(path); + var hash = Convert.ToBase64String(SHA256.HashData(bytes)); + string asmName = Path.GetFileNameWithoutExtension(file), version = "", mName = "", mVer = "", mAuthor = "", mLink = ""; + try + { + using var pe = new PEReader(new MemoryStream(bytes)); + if (pe.HasMetadata) + { + var md = pe.GetMetadataReader(); + var def = md.GetAssemblyDefinition(); + asmName = md.GetString(def.Name); + version = def.Version.ToString(); + foreach (var h in def.GetCustomAttributes()) + { + var attr = md.GetCustomAttribute(h); + if (!IsMelonInfo(md, attr.Constructor)) continue; + var strings = attr.DecodeValue(new Provider()).FixedArguments + .Where(a => a.Type is PrimitiveTypeCode.String && a.Value is string).Select(a => (string)a.Value!).ToList(); + if (strings.Count > 0) mName = strings[0]; + if (strings.Count > 1) mVer = strings[1]; + if (strings.Count > 2) mAuthor = strings[2]; + if (strings.Count > 3) mLink = strings[3]; + break; + } + } + } + catch (Exception e) when (e is BadImageFormatException or InvalidOperationException or ArgumentException) { } + var fv = System.Diagnostics.FileVersionInfo.GetVersionInfo(path).ProductVersion?.Split('+', '-')[0]; + return new AssemblyDetails(file, asmName, string.IsNullOrEmpty(fv) ? version : fv, mName, mVer, mAuthor, mLink, hash); + } + + static bool IsMelonInfo(MetadataReader md, EntityHandle ctor) + { + if (ctor.Kind != HandleKind.MemberReference) return false; + var member = md.GetMemberReference((MemberReferenceHandle)ctor); + if (member.Parent.Kind != HandleKind.TypeReference) return false; + var type = md.GetTypeReference((TypeReferenceHandle)member.Parent); + return md.GetString(type.Name) == "MelonInfoAttribute"; + } + + sealed class Provider : ICustomAttributeTypeProvider + { + const string SystemType = "System.Type"; + public object? GetPrimitiveType(PrimitiveTypeCode typeCode) => typeCode; + public object? GetSystemType() => SystemType; + public object? GetSZArrayType(object? elementType) => elementType; + public object? GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) => new object(); + public object? GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + { + var t = reader.GetTypeReference(handle); + return reader.GetString(t.Name) == "Type" && reader.GetString(t.Namespace) == "System" ? SystemType : new object(); + } + public object? GetTypeFromSerializedName(string name) => name; + public PrimitiveTypeCode GetUnderlyingEnumType(object? type) => PrimitiveTypeCode.Int32; + public bool IsSystemType(object? type) => type is string s && s == SystemType; + } +} diff --git a/Services/AvailableModsService.cs b/Services/AvailableModsService.cs new file mode 100644 index 0000000..efa3af6 --- /dev/null +++ b/Services/AvailableModsService.cs @@ -0,0 +1,69 @@ +using CVRManager.Models; + +namespace CVRManager.Services; + +public record RepoOffer(string Repo, string Asset, List Releases); + +public class AvailableEntry +{ + public string Key { get; init; } = ""; + public HashSet Keys { get; } = new(); + public string Name { get; set; } = ""; + public CvrmgMod? Cvrmg { get; set; } + public List Repos { get; } = new(); +} + +public static class AvailableModsService +{ + public static async Task> BuildAsync( + CvrmgService cvrmg, ModImportService importer, IReadOnlyList sources, bool force, Action log, CancellationToken ct) + { + if (force) importer.Invalidate(); + var byKey = new Dictionary(); + var entries = new List(); + + if (sources.Any(s => s.Kind == "cvrmg")) + { + try + { + foreach (var m in await cvrmg.GetAllAsync(ct, force)) + { + var e = new AvailableEntry { Key = CvrmgService.Normalize(m.Name), Name = m.Name, Cvrmg = m }; + foreach (var n in new[] { m.Name }.Concat(m.Aliases)) e.Keys.Add(CvrmgService.Normalize(n)); + e.Keys.RemoveWhere(k => k.Length == 0); + if (e.Keys.Count == 0) continue; + entries.Add(e); + foreach (var k in e.Keys) byKey.TryAdd(k, e); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + log($"CVRMG could not be loaded: {ex.Message}"); + } + } + + foreach (var src in sources.Where(s => s.Kind == "github" && s.Repo.Length > 0)) + { + var releases = await importer.ReleasesAsync(src.Repo, log, ct, force); + var assets = new Dictionary(); + foreach (var r in releases) + foreach (var a in r.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))) + assets.TryAdd(CvrmgService.Normalize(Path.GetFileNameWithoutExtension(a.Name)), a.Name); + + foreach (var (key, asset) in assets) + { + if (key.Length == 0) continue; + if (!byKey.TryGetValue(key, out var entry)) + { + entry = new AvailableEntry { Key = key, Name = Path.GetFileNameWithoutExtension(asset) }; + entry.Keys.Add(key); + byKey[key] = entry; + entries.Add(entry); + } + if (entry.Repos.All(o => !o.Repo.Equals(src.Repo, StringComparison.OrdinalIgnoreCase))) + entry.Repos.Add(new RepoOffer(src.Repo, asset, ModImportService.FilterReleases(releases, asset))); + } + } + return entries; + } +} diff --git a/Services/BranchCodeService.cs b/Services/BranchCodeService.cs new file mode 100644 index 0000000..0712452 --- /dev/null +++ b/Services/BranchCodeService.cs @@ -0,0 +1,36 @@ +using SteamKit2; + +namespace CVRManager.Services; + +public static class BranchCodeService +{ + public static async Task> ResolveAsync(string code, string account, string refreshToken, CancellationToken ct) + { + await using var link = await SteamLink.OpenAsync(account, refreshToken, ct); + return await ResolveAsync(link, code); + } + + public static async Task> ResolveAsync(SteamLink link, string code) + { + var call = link.Apps.CheckAppBetaPassword((uint)Paths.AppId, code).ToTask(); + if (await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(25))) != call) + throw new TimeoutException("Steam did not answer the code check. It limits how often codes can be checked; try again in a few minutes."); + var reply = await call; + if (reply.Result == EResult.OK) return new Dictionary(reply.BetaPasswords); + throw new InvalidOperationException(reply.Result switch + { + EResult.InvalidPassword => "Steam did not accept that access code.", + EResult.RateLimitExceeded => "Steam is rate limiting code checks. Wait a few minutes and try again.", + _ => $"Steam could not check the code ({reply.Result}){(link.IsAnonymous ? "; sign in to Steam in Settings and try again" : "")}.", + }); + } + + public static string ProtectKey(byte[] key) => TokenVault.Protect(Convert.ToBase64String(key)); + + public static byte[]? UnprotectKey(string stored) + { + var text = TokenVault.Unprotect(stored); + try { return text.Length == 0 ? null : Convert.FromBase64String(text); } + catch (FormatException) { return null; } + } +} diff --git a/Services/CacheGuardService.cs b/Services/CacheGuardService.cs new file mode 100644 index 0000000..f830700 --- /dev/null +++ b/Services/CacheGuardService.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CVRManager.Services; + +public static class CacheGuardService +{ + const string AssemblyName = "CVRCacheGuard"; + const string AssemblyFile = AssemblyName + ".dll"; + const int UserAssemblyType = 16; + + static string Source => Path.Combine(AppContext.BaseDirectory, "CacheGuard"); + + static string Managed(string gameDir) => Path.Combine(gameDir, "ChilloutVR_Data", "Managed"); + + public static void Deploy(string gameDir, Action log, string? source = null) + { + var managed = Managed(gameDir); + var scripting = Path.Combine(gameDir, "ChilloutVR_Data", "ScriptingAssemblies.json"); + var init = Path.Combine(gameDir, "ChilloutVR_Data", "RuntimeInitializeOnLoads.json"); + source ??= Source; + if (!Directory.Exists(source) || !Directory.Exists(managed) || !File.Exists(scripting) || !File.Exists(init)) return; + + var changed = false; + var deps = Path.Combine(managed, AssemblyName); + Directory.CreateDirectory(deps); + foreach (var file in Directory.EnumerateFiles(source, "*.dll")) + { + var name = Path.GetFileName(file); + var target = name == AssemblyFile ? Path.Combine(managed, name) : Path.Combine(deps, name); + changed |= CopyIfDifferent(file, target); + } + + var scriptingJson = JsonNode.Parse(File.ReadAllText(scripting))!.AsObject(); + var names = scriptingJson["names"]!.AsArray(); + var types = scriptingJson["types"]!.AsArray(); + if (!names.Any(n => n?.GetValue() == AssemblyFile)) + { + names.Add(AssemblyFile); + types.Add(UserAssemblyType); + WriteJson(scripting, scriptingJson); + changed = true; + } + + var initJson = JsonNode.Parse(File.ReadAllText(init))!.AsObject(); + var root = initJson["root"]!.AsArray(); + if (!root.Any(e => e?["assemblyName"]?.GetValue() == AssemblyName)) + { + root.Add(new JsonObject + { + ["assemblyName"] = AssemblyName, + ["nameSpace"] = AssemblyName, + ["className"] = "Entry", + ["methodName"] = "Init", + ["loadTypes"] = 2, + ["isUnityClass"] = false, + }); + WriteJson(init, initJson); + changed = true; + } + if (changed) log("Multi-instance cache guard added to this install."); + } + + public static void Remove(string gameDir, Action log) + { + var data = Path.Combine(gameDir, "ChilloutVR_Data"); + var scripting = Path.Combine(data, "ScriptingAssemblies.json"); + var init = Path.Combine(data, "RuntimeInitializeOnLoads.json"); + var changed = false; + + if (File.Exists(scripting)) + { + var json = JsonNode.Parse(File.ReadAllText(scripting))!.AsObject(); + var names = json["names"]!.AsArray(); + var types = json["types"]!.AsArray(); + for (var i = names.Count - 1; i >= 0; i--) + if (names[i]?.GetValue() == AssemblyFile) + { + names.RemoveAt(i); + if (i < types.Count) types.RemoveAt(i); + changed = true; + } + if (changed) WriteJson(scripting, json); + } + if (File.Exists(init)) + { + var json = JsonNode.Parse(File.ReadAllText(init))!.AsObject(); + var root = json["root"]!.AsArray(); + var before = root.Count; + for (var i = root.Count - 1; i >= 0; i--) + if (root[i]?["assemblyName"]?.GetValue() == AssemblyName) root.RemoveAt(i); + if (root.Count != before) + { + WriteJson(init, json); + changed = true; + } + } + + var managed = Managed(gameDir); + try + { + File.Delete(Path.Combine(managed, AssemblyFile)); + var deps = Path.Combine(managed, AssemblyName); + if (Directory.Exists(deps)) Directory.Delete(deps, true); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { } + if (changed) log("Multi-instance cache guard removed from this install."); + } + + static bool CopyIfDifferent(string source, string target) + { + var s = new FileInfo(source); + var t = new FileInfo(target); + if (t.Exists && t.Length == s.Length && t.LastWriteTimeUtc == s.LastWriteTimeUtc) return false; + try + { + File.Copy(source, target, true); + File.SetLastWriteTimeUtc(target, s.LastWriteTimeUtc); + return true; + } + catch (IOException) when (t.Exists) + { + return false; + } + } + + static void WriteJson(string path, JsonNode node) => + File.WriteAllText(path, node.ToJsonString(new JsonSerializerOptions { WriteIndented = false }), new System.Text.UTF8Encoding(false)); +} diff --git a/Services/CacheService.cs b/Services/CacheService.cs new file mode 100644 index 0000000..e969b8f --- /dev/null +++ b/Services/CacheService.cs @@ -0,0 +1,169 @@ +using System.Diagnostics; +using CVRManager.Models; + +namespace CVRManager.Services; + +public enum CacheState { Missing, Shared, Local, LinkedElsewhere } + +public static class CacheService +{ + public static CacheState GetState(InstallInfo install) + { + var path = Paths.CacheDir(install); + var info = new DirectoryInfo(path); + if (!info.Exists) return CacheState.Missing; + if (!info.Attributes.HasFlag(FileAttributes.ReparsePoint)) return CacheState.Local; + var target = info.LinkTarget; + return target != null && SamePath(target, Paths.SharedCache) ? CacheState.Shared : CacheState.LinkedElsewhere; + } + + static bool SamePath(string a, string b) => + string.Equals(Path.GetFullPath(a).TrimEnd('\\'), Path.GetFullPath(b).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase); + + public static Task LinkAsync(InstallInfo install, Action log, CancellationToken ct) => Task.Run(() => + { + var path = Paths.CacheDir(install); + Directory.CreateDirectory(Paths.SharedCache); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + switch (GetState(install)) + { + case CacheState.Shared: + return; + case CacheState.Local: + MergeInto(path, Paths.SharedCache, log, ct); + if (Directory.EnumerateFileSystemEntries(path).Any()) + throw new IOException($"Cache folder still has content after merge: {path}"); + Directory.Delete(path); + break; + case CacheState.LinkedElsewhere: + Directory.Delete(path, false); + break; + } + CreateJunction(path, Paths.SharedCache); + log("Cache now points at the shared cache."); + }, ct); + + public static void MergeInto(string source, string shared, Action log, CancellationToken ct) + { + Directory.CreateDirectory(shared); + var moved = 0; + var dupes = 0; + var sameRoot = string.Equals(Path.GetPathRoot(Path.GetFullPath(source)), Path.GetPathRoot(Path.GetFullPath(shared)), StringComparison.OrdinalIgnoreCase); + + foreach (var entry in Directory.EnumerateFileSystemEntries(source).ToList()) + { + ct.ThrowIfCancellationRequested(); + var name = Path.GetFileName(entry); + var dest = Path.Combine(shared, name); + var isDir = Directory.Exists(entry); + + if (!(isDir ? Directory.Exists(dest) : File.Exists(dest))) + { + if (isDir) + { + if (sameRoot) Directory.Move(entry, dest); + else + { + CopyTree(entry, dest, ct); + Directory.Delete(entry, true); + } + } + else File.Move(entry, dest); + moved++; + } + else if (isDir) + { + if (MergeTree(entry, dest, ct)) Directory.Delete(entry, true); + dupes++; + } + else + { + if (new FileInfo(entry).Length == new FileInfo(dest).Length) File.Delete(entry); + dupes++; + } + } + log($"Cache merge: {moved} entries moved, {dupes} duplicates removed."); + } + + public static void CopyInto(string source, string shared, Action log, CancellationToken ct) + { + Directory.CreateDirectory(shared); + var copied = 0; + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + ct.ThrowIfCancellationRequested(); + var target = Path.Combine(shared, Path.GetRelativePath(source, file)); + if (File.Exists(target)) continue; + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + copied++; + } + log($"Cache copy: {copied} files added to the shared cache."); + } + + static bool MergeTree(string src, string dst, CancellationToken ct) + { + var clean = true; + foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories).ToList()) + { + ct.ThrowIfCancellationRequested(); + var rel = Path.GetRelativePath(src, file); + var target = Path.Combine(dst, rel); + if (!File.Exists(target)) + { + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Move(file, target); + } + else if (new FileInfo(file).Length == new FileInfo(target).Length) File.Delete(file); + else clean = false; + } + return clean; + } + + static void CopyTree(string src, string dst, CancellationToken ct) + { + Directory.CreateDirectory(dst); + foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories)) + { + ct.ThrowIfCancellationRequested(); + var target = Path.Combine(dst, Path.GetRelativePath(src, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, true); + } + } + + public static void CreateJunction(string link, string target) + { + var psi = new ProcessStartInfo("cmd.exe") + { + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + psi.ArgumentList.Add("/c"); + psi.ArgumentList.Add("mklink"); + psi.ArgumentList.Add("/J"); + psi.ArgumentList.Add(link); + psi.ArgumentList.Add(target); + using var p = Process.Start(psi)!; + var err = p.StandardError.ReadToEnd() + p.StandardOutput.ReadToEnd(); + p.WaitForExit(); + if (p.ExitCode != 0) throw new IOException($"mklink failed: {err.Trim()}"); + } + + public static void RemoveLinkIfJunction(string path) + { + var info = new DirectoryInfo(path); + if (info.Exists && info.Attributes.HasFlag(FileAttributes.ReparsePoint)) Directory.Delete(path, false); + } + + public static long SizeOf(string path) + { + if (!Directory.Exists(path)) return 0; + long total = 0; + foreach (var f in new DirectoryInfo(path).EnumerateFiles("*", SearchOption.AllDirectories)) total += f.Length; + return total; + } +} diff --git a/Services/ConfigStore.cs b/Services/ConfigStore.cs new file mode 100644 index 0000000..c7d4a1f --- /dev/null +++ b/Services/ConfigStore.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using CVRManager.Models; + +namespace CVRManager.Services; + +public static class ConfigStore +{ + static readonly JsonSerializerOptions Options = new() { WriteIndented = true }; + + public static AppConfig Load() + { + AppConfig cfg; + try + { + cfg = File.Exists(Paths.ConfigFile) + ? JsonSerializer.Deserialize(File.ReadAllText(Paths.ConfigFile), Options) ?? new AppConfig() + : new AppConfig(); + } + catch + { + File.Copy(Paths.ConfigFile, Paths.ConfigFile + ".broken", true); + cfg = new AppConfig(); + } + + if (string.IsNullOrWhiteSpace(cfg.LibraryRoot)) cfg.LibraryRoot = Paths.Root; + Paths.Root = cfg.LibraryRoot; + + if (cfg.Mods.All(m => m.Id != ModDef.MelonLoaderId)) + cfg.Mods.Insert(0, new ModDef + { + Id = ModDef.MelonLoaderId, + Name = "MelonLoader", + Repo = "LavaGang/MelonLoader", + Type = "Root", + AssetFilter = "MelonLoader.x64.zip", + }); + EnsureSources(cfg); + return cfg; + } + + static readonly (string Name, string Repo)[] DefaultRepos = + { + ("NAK CVR Mods", "NotAKidoS/NAK_CVR_Mods"), + ("Kafe CVR Mods", "kafeijao/Kafe_CVR_Mods"), + ("SDraw mods", "SDraw/ml_mods_cvr"), + ("Nirv mods", "Nirv-git/CVRMods-Nirv"), + ("Daky mods", "dakyneko/DakyModsCVR"), + ("ddakebono mods", "ddakebono/Chillout-VR-Mods"), + }; + + static void EnsureSources(AppConfig cfg) + { + if (cfg.Sources.All(s => s.Id != SourceDef.CvrmgId)) + cfg.Sources.Insert(0, new SourceDef { Id = SourceDef.CvrmgId, Name = "CVRMG (approved mods)", Kind = "cvrmg", Builtin = true }); + foreach (var (name, repo) in DefaultRepos) + if (cfg.Sources.All(s => !s.Repo.Equals(repo, StringComparison.OrdinalIgnoreCase)) && !cfg.SeededSources.Contains(repo)) + { + cfg.Sources.Add(new SourceDef { Id = repo.Replace('/', '_'), Name = name, Kind = "github", Repo = repo, Builtin = true }); + cfg.SeededSources.Add(repo); + } + } + + public static List LoadBranches() + { + try + { + return File.Exists(Paths.BranchCache) + ? JsonSerializer.Deserialize>(File.ReadAllText(Paths.BranchCache), Options) ?? new() + : new(); + } + catch { return new(); } + } + + public static void SaveBranches(IEnumerable branches) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(Paths.BranchCache)!); + File.WriteAllText(Paths.BranchCache, JsonSerializer.Serialize(branches, Options)); + } + catch { } + } + + public static void Save(AppConfig cfg) + { + Directory.CreateDirectory(Paths.ConfigDir); + var tmp = Paths.ConfigFile + ".tmp"; + File.WriteAllText(tmp, JsonSerializer.Serialize(cfg, Options)); + File.Move(tmp, Paths.ConfigFile, true); + } +} diff --git a/Services/CvrmgService.cs b/Services/CvrmgService.cs new file mode 100644 index 0000000..8a6c934 --- /dev/null +++ b/Services/CvrmgService.cs @@ -0,0 +1,101 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; +using CVRManager.Models; + +namespace CVRManager.Services; + +public record CvrmgMod( + int Id, string Name, string[] Aliases, string Category, string ModVersion, string GameVersion, string LoaderVersion, + string Type, string Author, string Description, string DownloadLink, string SourceLink, string Hash, int ApprovalStatus, DateTime Updated) +{ + public string StatusText => ApprovalStatus switch { 1 => "approved", 2 => "outdated", 3 => "obsolete", _ => "unknown" }; + public string ModType => Type.Equals("Plugin", StringComparison.OrdinalIgnoreCase) ? "Plugins" : "Mods"; +} + +public class CvrmgService +{ + const string ListUrl = "https://api.cvrmg.com/v1/mods"; + readonly HttpClient http = new(); + List? cache; + DateTime fetched; + + public CvrmgService() + { + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", "1.0")); + } + + public static string Normalize(string s) => Regex.Replace(s ?? "", "[^a-z0-9]", "", RegexOptions.IgnoreCase).ToLowerInvariant(); + + public async Task> GetAllAsync(CancellationToken ct, bool force = false) + { + if (!force && cache != null && DateTime.UtcNow - fetched < TimeSpan.FromMinutes(10)) return cache; + var path = Path.Combine(Paths.Root, "sources", "cvrmg.json"); + string? json = null; + if (!force && File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < TimeSpan.FromHours(1)) + json = await File.ReadAllTextAsync(path, ct); + if (json == null) + { + try + { + using var resp = await http.GetAsync(ListUrl, ct); + if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CVRMG returned {(int)resp.StatusCode}."); + json = await resp.Content.ReadAsStringAsync(ct); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, json, ct); + } + catch (Exception e) when (e is not OperationCanceledException && File.Exists(path)) + { + json = await File.ReadAllTextAsync(path, ct); + } + } + using var doc = JsonDocument.Parse(json); + var list = new List(); + foreach (var m in doc.RootElement.EnumerateArray()) + { + if (!m.TryGetProperty("versions", out var versions) || versions.ValueKind != JsonValueKind.Array) continue; + JsonElement? best = null; + var bestNo = int.MinValue; + foreach (var v in versions.EnumerateArray()) + { + var no = v.TryGetProperty("_version", out var n) && n.ValueKind == JsonValueKind.Number ? n.GetInt32() : 0; + if (no >= bestNo) { best = v; bestNo = no; } + } + if (best is not { } ver) continue; + + string S(JsonElement e, string k) => e.TryGetProperty(k, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() ?? "" : ""; + var aliases = m.TryGetProperty("aliases", out var al) && al.ValueKind == JsonValueKind.Array + ? al.EnumerateArray().Where(a => a.ValueKind == JsonValueKind.String).Select(a => a.GetString()!).ToArray() + : Array.Empty(); + DateTime.TryParse(S(ver, "updateDate"), null, System.Globalization.DateTimeStyles.AdjustToUniversal, out var updated); + list.Add(new CvrmgMod( + m.GetProperty("_id").GetInt32(), S(ver, "name"), aliases, S(m, "category"), + S(ver, "modVersion"), S(ver, "gameVersion"), S(ver, "loaderVersion").TrimStart('v'), S(ver, "modType"), + S(ver, "author"), S(ver, "description"), S(ver, "downloadLink"), S(ver, "sourceLink"), S(ver, "hash"), + ver.TryGetProperty("approvalStatus", out var st) && st.ValueKind == JsonValueKind.Number ? st.GetInt32() : 0, updated)); + } + cache = list; + fetched = DateTime.UtcNow; + return list; + } + + public static ReleaseInfo ToRelease(CvrmgMod m, string fileName) => new() + { + Tag = m.ModVersion, + Published = m.Updated, + GameVersion = m.GameVersion, + LoaderVersion = m.LoaderVersion, + Assets = { new AssetInfo { Name = fileName, Url = m.DownloadLink, Hash = m.Hash } }, + }; + + public static void ApplyMeta(ModDef def, CvrmgMod m) + { + def.CvrmgId = m.Id; + if (def.Author.Length == 0) def.Author = m.Author; + def.Description = m.Description; + def.LatestVersion = m.ModVersion; + def.Category = m.Category; + def.Status = m.StatusText; + if (def.SourceLink.Length == 0) def.SourceLink = m.SourceLink; + } +} diff --git a/Services/FileOps.cs b/Services/FileOps.cs new file mode 100644 index 0000000..a91fd9f --- /dev/null +++ b/Services/FileOps.cs @@ -0,0 +1,43 @@ +namespace CVRManager.Services; + +public static class FileOps +{ + public static void CopyTree(string src, string dst, Func skip, Action log, CancellationToken ct) + { + Directory.CreateDirectory(dst); + var count = 0; + foreach (var entry in new DirectoryInfo(src).EnumerateFileSystemInfos()) + { + ct.ThrowIfCancellationRequested(); + if (skip(entry.FullName)) continue; + var target = Path.Combine(dst, entry.Name); + if (entry is DirectoryInfo d) + { + if (d.Attributes.HasFlag(FileAttributes.ReparsePoint)) continue; + CopyTree(d.FullName, target, skip, log, ct); + } + else + { + File.Copy(entry.FullName, target, true); + if (++count % 200 == 0) log($"Copying {Path.GetFileName(src)}..."); + } + } + } + + public static void DeleteDirectorySafe(string path) + { + if (!Directory.Exists(path)) return; + foreach (var d in new DirectoryInfo(path).EnumerateDirectories("*", SearchOption.AllDirectories).ToList()) + if (d.Exists && d.Attributes.HasFlag(FileAttributes.ReparsePoint)) d.Delete(false); + Directory.Delete(path, true); + } + + public static string Slug(string name) + { + var s = new string(name.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()).Trim('-'); + return s.Length == 0 ? "install" : s; + } + + public static string FormatSize(long bytes) => + bytes >= 1L << 30 ? $"{bytes / (double)(1L << 30):0.0} GB" : $"{bytes / (double)(1L << 20):0} MB"; +} diff --git a/Services/GameWindow.cs b/Services/GameWindow.cs new file mode 100644 index 0000000..e9bee03 --- /dev/null +++ b/Services/GameWindow.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +namespace CVRManager.Services; + +public static class GameWindow +{ + const uint WM_SETICON = 0x0080; + const int ICON_SMALL = 0; + const int ICON_BIG = 1; + const int SM_CXICON = 11; + const int SM_CXSMICON = 49; + + delegate bool EnumProc(IntPtr hwnd, IntPtr lParam); + + [DllImport("user32.dll")] + static extern bool EnumWindows(EnumProc proc, IntPtr lParam); + + [DllImport("user32.dll")] + static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); + + [DllImport("user32.dll")] + static extern bool IsWindowVisible(IntPtr hwnd); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + static extern int GetClassName(IntPtr hwnd, StringBuilder name, int max); + + [DllImport("user32.dll", SetLastError = true)] + static extern IntPtr SendMessageTimeout(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam, uint flags, uint timeout, out IntPtr result); + + const uint SMTO_ABORTIFHUNG = 2; + + [DllImport("user32.dll")] + static extern int GetSystemMetrics(int index); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + static extern uint PrivateExtractIcons(string file, int index, int cx, int cy, IntPtr[] icons, uint[] ids, uint count, uint flags); + + static IntPtr FindUnityWindow(int pid) + { + var found = IntPtr.Zero; + var name = new StringBuilder(64); + EnumWindows((hwnd, _) => + { + GetWindowThreadProcessId(hwnd, out var owner); + if (owner != pid || !IsWindowVisible(hwnd)) return true; + name.Clear(); + GetClassName(hwnd, name, name.Capacity); + if (name.ToString() == "UnityWndClass") found = hwnd; + return true; + }, IntPtr.Zero); + return found; + } + + static void SetIcons(IntPtr hwnd, string exe) + { + foreach (var (kind, size) in new[] { (ICON_SMALL, GetSystemMetrics(SM_CXSMICON)), (ICON_BIG, GetSystemMetrics(SM_CXICON)) }) + { + var icons = new IntPtr[1]; + var ids = new uint[1]; + if (PrivateExtractIcons(exe, 0, size, size, icons, ids, 1, 0) > 0 && icons[0] != IntPtr.Zero) + SendMessageTimeout(hwnd, WM_SETICON, (IntPtr)kind, icons[0], SMTO_ABORTIFHUNG, 1000, out _); + } + } + + public static async Task ApplyIconWhenReadyAsync(Process proc, string exe) + { + if (!OperatingSystem.IsWindows()) return; + try + { + var hwnd = IntPtr.Zero; + for (var i = 0; i < 240 && hwnd == IntPtr.Zero; i++) + { + if (proc.HasExited) return; + hwnd = FindUnityWindow(proc.Id); + if (hwnd == IntPtr.Zero) await Task.Delay(250); + } + if (hwnd == IntPtr.Zero) return; + + foreach (var wait in new[] { 0, 3000, 7000, 20000 }) + { + await Task.Delay(wait); + if (proc.HasExited) return; + var current = FindUnityWindow(proc.Id); + if (current != IntPtr.Zero) SetIcons(current, exe); + } + } + catch (InvalidOperationException) { } + } +} diff --git a/Services/GitHubService.cs b/Services/GitHubService.cs new file mode 100644 index 0000000..f4f03a7 --- /dev/null +++ b/Services/GitHubService.cs @@ -0,0 +1,102 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.RegularExpressions; +using CVRManager.Models; + +namespace CVRManager.Services; + +public class GitHubService +{ + readonly HttpClient http = new(); + readonly Func token; + + public GitHubService(Func token) + { + this.token = token; + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", "1.0")); + http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + } + + public static string? ParseRepo(string input) + { + input = input.Trim(); + var m = Regex.Match(input, @"^(?:https?://github\.com/)?([\w.-]+)/([\w.-]+?)(?:\.git)?(?:/.*)?$"); + return m.Success ? $"{m.Groups[1].Value}/{m.Groups[2].Value}" : null; + } + + HttpRequestMessage Request(string url) + { + var req = new HttpRequestMessage(HttpMethod.Get, url); + var t = token(); + if (!string.IsNullOrWhiteSpace(t)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", t); + return req; + } + + public async Task> GetReleasesAsync(string repo, CancellationToken ct) + { + using var resp = await http.SendAsync(Request($"https://api.github.com/repos/{repo}/releases?per_page=40"), ct); + if (!resp.IsSuccessStatusCode) + { + var hint = (int)resp.StatusCode is 403 or 429 ? " (rate limited: add a GitHub token in Settings)" : ""; + throw new InvalidOperationException($"GitHub returned {(int)resp.StatusCode} for {repo}{hint}."); + } + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + var list = new List(); + foreach (var r in doc.RootElement.EnumerateArray()) + { + if (r.GetProperty("draft").GetBoolean()) continue; + var rel = new ReleaseInfo + { + Tag = r.GetProperty("tag_name").GetString() ?? "", + Prerelease = r.GetProperty("prerelease").GetBoolean(), + Published = r.TryGetProperty("published_at", out var p) && p.ValueKind == JsonValueKind.String ? p.GetDateTime() : default, + }; + foreach (var a in r.GetProperty("assets").EnumerateArray()) + rel.Assets.Add(new AssetInfo + { + Name = a.GetProperty("name").GetString() ?? "", + Url = a.GetProperty("browser_download_url").GetString() ?? "", + Size = a.GetProperty("size").GetInt64(), + }); + list.Add(rel); + } + return list; + } + + public async Task GetMelonLoaderNightlyAsync(CancellationToken ct) + { + using var resp = await http.SendAsync(Request( + "https://api.github.com/repos/LavaGang/MelonLoader/actions/workflows/build.yml/runs?branch=master&status=success&per_page=1"), ct); + if (!resp.IsSuccessStatusCode) return null; + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + var runs = doc.RootElement.GetProperty("workflow_runs"); + if (runs.GetArrayLength() == 0) return null; + var run = runs[0]; + var sha = run.GetProperty("head_sha").GetString() ?? ""; + var id = run.GetProperty("id").GetInt64(); + var rel = new ReleaseInfo + { + Tag = $"nightly-{sha[..Math.Min(7, sha.Length)]}", + Prerelease = true, + Published = run.GetProperty("created_at").GetDateTime(), + }; + rel.Assets.Add(new AssetInfo + { + Name = "MelonLoader.x64.zip", + Url = $"https://nightly.link/LavaGang/MelonLoader/actions/runs/{id}/MelonLoader.Windows.x64.CI.Release.zip", + }); + return rel; + } + + public async Task DownloadAsync(string url, string path, CancellationToken ct) + { + using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await using var src = await resp.Content.ReadAsStreamAsync(ct); + await using var dst = File.Create(path); + await src.CopyToAsync(dst, ct); + var last = resp.RequestMessage?.RequestUri?.Segments.LastOrDefault() ?? ""; + return Uri.UnescapeDataString(last.TrimEnd('/')); + } +} diff --git a/Services/HookService.cs b/Services/HookService.cs new file mode 100644 index 0000000..030a3f5 --- /dev/null +++ b/Services/HookService.cs @@ -0,0 +1,130 @@ +using System.Diagnostics; +using System.Text; +using CVRManager.Models; + +namespace CVRManager.Services; + +public static class HookService +{ + static readonly TimeSpan Timeout = TimeSpan.FromMinutes(5); + + public static async Task> RunEventAsync( + IEnumerable hooks, InstallInfo install, string evt, string buildId, Action log, CancellationToken ct) + { + var failures = new List(); + foreach (var hook in hooks.Where(h => install.HookIds.Contains(h.Id) && h.Runs(evt))) + { + try + { + if (!await RunAsync(hook, install, buildId, log, ct)) failures.Add(hook.Name); + } + catch (OperationCanceledException) { throw; } + catch (Exception e) + { + log($"[{hook.Name}] failed: {e.Message}"); + failures.Add(hook.Name); + } + } + return failures; + } + + public static async Task RunAsync(HookDef hook, InstallInfo install, string buildId, Action log, CancellationToken ct) + { + var game = Paths.GameDir(install); + if (!Directory.Exists(Path.Combine(game, "ChilloutVR_Data"))) + { + log($"[{hook.Name}] skipped: the game files are not installed yet."); + return true; + } + log($"[{hook.Name}] running for {install.Name}..."); + return await RunCommandAsync(hook, install, game, buildId, log, ct); + } + + static async Task RunCommandAsync(HookDef hook, InstallInfo install, string game, string buildId, Action log, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(hook.Command)) throw new InvalidOperationException("The hook has no command set."); + string Expand(string s) => s + .Replace("{game}", game, StringComparison.OrdinalIgnoreCase) + .Replace("{install}", install.Name, StringComparison.OrdinalIgnoreCase) + .Replace("{branch}", install.Branch, StringComparison.OrdinalIgnoreCase) + .Replace("{build}", buildId, StringComparison.OrdinalIgnoreCase); + + var command = Expand(hook.Command.Trim()); + var args = SplitArgs(Expand(hook.Arguments)).ToList(); + var ext = Path.GetExtension(command).ToLowerInvariant(); + ProcessStartInfo psi; + if (ext == ".ps1") + { + psi = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe")); + foreach (var a in new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", command }) psi.ArgumentList.Add(a); + } + else if (ext is ".bat" or ".cmd") + { + psi = new ProcessStartInfo("cmd.exe"); + psi.ArgumentList.Add("/c"); + psi.ArgumentList.Add(command); + } + else psi = new ProcessStartInfo(command); + foreach (var a in args) psi.ArgumentList.Add(a); + + psi.Environment["CVR_GAME_DIR"] = game; + psi.Environment["CVR_INSTALL_NAME"] = install.Name; + psi.Environment["CVR_BRANCH"] = install.Branch; + psi.Environment["CVR_BUILD"] = buildId; + + var code = await RunProcessAsync(psi, game, hook.Name, log, ct); + if (code != 0) log($"[{hook.Name}] exited with code {code}"); + return code == 0; + } + + static async Task RunProcessAsync(ProcessStartInfo psi, string workDir, string label, Action log, CancellationToken ct) + { + psi.WorkingDirectory = workDir; + psi.UseShellExecute = false; + psi.CreateNoWindow = true; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.RedirectStandardInput = true; + psi.StandardOutputEncoding = Encoding.UTF8; + psi.StandardErrorEncoding = Encoding.UTF8; + + using var proc = new Process { StartInfo = psi }; + proc.OutputDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) log($"[{label}] {e.Data.TrimEnd()}"); }; + proc.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) log($"[{label}] {e.Data.TrimEnd()}"); }; + proc.Start(); + proc.StandardInput.Close(); + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(Timeout); + try + { + await proc.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + try { proc.Kill(true); } catch { } + if (ct.IsCancellationRequested) throw; + throw new TimeoutException($"timed out after {Timeout.TotalMinutes:0} minutes"); + } + proc.WaitForExit(); + return proc.ExitCode; + } + + public static IEnumerable SplitArgs(string s) + { + var sb = new StringBuilder(); + var quoted = false; + foreach (var c in s) + { + if (c == '"') quoted = !quoted; + else if (char.IsWhiteSpace(c) && !quoted) + { + if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); } + } + else sb.Append(c); + } + if (sb.Length > 0) yield return sb.ToString(); + } +} diff --git a/Services/InstallFolders.cs b/Services/InstallFolders.cs new file mode 100644 index 0000000..e26916d --- /dev/null +++ b/Services/InstallFolders.cs @@ -0,0 +1,35 @@ +using CVRManager.Models; + +namespace CVRManager.Services; + +public static class InstallFolders +{ + public static string UniqueFolder(IEnumerable all, InstallInfo self, string name) + { + var stem = $"{Paths.SafeFolderName(name)}-{self.Id[..4]}"; + var candidate = stem; + for (var n = 2; Taken(all, self, candidate); n++) candidate = $"{stem}-{n}"; + return candidate; + } + + static bool Taken(IEnumerable all, InstallInfo self, string candidate) + { + if (all.Any(i => !ReferenceEquals(i, self) && i.Folder.Equals(candidate, StringComparison.OrdinalIgnoreCase))) return true; + if (candidate.Equals(self.Folder, StringComparison.OrdinalIgnoreCase)) return false; + return Directory.Exists(Path.Combine(Paths.InstallsRoot, candidate)); + } + + public static void Rename(InstallInfo info, string desiredFolder) + { + var oldDir = Paths.InstallDir(info); + var newDir = Path.Combine(Paths.InstallsRoot, desiredFolder); + if (oldDir.Equals(newDir, StringComparison.Ordinal)) return; + if (oldDir.Equals(newDir, StringComparison.OrdinalIgnoreCase)) + { + var temp = oldDir + ".renaming"; + Directory.Move(oldDir, temp); + Directory.Move(temp, newDir); + } + else Directory.Move(oldDir, newDir); + } +} diff --git a/Services/MlLogColours.cs b/Services/MlLogColours.cs new file mode 100644 index 0000000..056b27c --- /dev/null +++ b/Services/MlLogColours.cs @@ -0,0 +1,99 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace CVRManager.Services; + +public static class MlLogColours +{ + const string Esc = "\u001b["; + const string Reset = "\u001b[0m"; + const long MaxScan = 16L << 20; + + static readonly Regex Sgr = new(@"\u001b\[([0-9;]*)m", RegexOptions.Compiled); + static readonly Regex AnyEsc = new(@"\u001b\[[0-9;?]*[A-Za-z]", RegexOptions.Compiled); + static readonly Regex Stamp = new(@"^\[(\d\d:\d\d:\d\d\.\d{3})\] (.*)$", RegexOptions.Compiled); + + sealed class FileIndex + { + public long Offset; + public string? State; + public readonly Dictionary Lines = new(StringComparer.Ordinal); + } + + static readonly Dictionary Files = new(StringComparer.OrdinalIgnoreCase); + static readonly object Gate = new(); + static long version; + + public static long Refresh(params string[] paths) + { + lock (Gate) + { + foreach (var path in paths) Scan(path); + return version; + } + } + + static void Scan(string path) + { + if (!Files.TryGetValue(path, out var index)) Files[path] = index = new FileIndex(); + try + { + if (!File.Exists(path)) + { + if (index.Offset != 0 || index.Lines.Count > 0) { Files[path] = new FileIndex(); version++; } + return; + } + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + var length = fs.Length; + if (length < index.Offset) { Files[path] = index = new FileIndex(); version++; } + if (length == index.Offset) return; + var start = Math.Max(index.Offset, length - MaxScan); + var buffer = new byte[length - start]; + fs.Seek(start, SeekOrigin.Begin); + fs.ReadExactly(buffer); + var end = Array.LastIndexOf(buffer, (byte)'\n'); + if (end < 0) return; + var text = Encoding.UTF8.GetString(buffer, 0, end + 1); + if (start > index.Offset) index.State = null; + index.Offset = start + end + 1; + foreach (var raw in text.Split('\n')) Add(index, raw.TrimEnd('\r')); + version++; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { } + } + + static void Add(FileIndex index, string line) + { + var hasEsc = line.IndexOf('\u001b') >= 0; + if (!hasEsc && index.State == null) return; + var styled = index.State == null ? line : Esc + index.State + "m" + line; + foreach (Match m in Sgr.Matches(line)) + { + var codes = m.Groups[1].Value; + index.State = codes is "" or "0" or "39" ? null : codes; + } + if (index.State != null) styled += Reset; + var plain = AnyEsc.Replace(line, ""); + if (plain.Trim().Length > 0) index.Lines[plain] = styled; + } + + public static string Colour(string mlText, params string[] paths) + { + var sb = new StringBuilder(mlText.Length * 2); + lock (Gate) + { + foreach (var raw in mlText.Split('\n')) + { + var line = raw.TrimEnd('\r'); + string? styled = null; + for (var i = paths.Length - 1; i >= 0 && styled == null; i--) + if (Files.TryGetValue(paths[i], out var index)) index.Lines.TryGetValue(line, out styled); + if (styled == null && Stamp.Match(line) is { Success: true } m) + styled = $"{Esc}38;2;211;211;211m[{Reset}{Esc}38;2;50;205;50m{m.Groups[1].Value}{Reset}{Esc}38;2;211;211;211m] {m.Groups[2].Value}{Reset}"; + sb.Append(styled ?? line).Append('\n'); + } + } + if (sb.Length > 0 && !mlText.EndsWith('\n')) sb.Length--; + return sb.ToString(); + } +} diff --git a/Services/ModImportService.cs b/Services/ModImportService.cs new file mode 100644 index 0000000..5100f07 --- /dev/null +++ b/Services/ModImportService.cs @@ -0,0 +1,217 @@ +using CVRManager.Models; + +namespace CVRManager.Services; + +public record Identified(AssemblyDetails File, string Folder, string Source, string Repo, string AssetName, CvrmgMod? Cvrmg, string Why); + +public class ModImportService +{ + static readonly string[] Folders = { "Mods", "Plugins", "UserLibs" }; + + readonly GitHubService github; + readonly CvrmgService cvrmg; + readonly Dictionary> repoReleases = new(StringComparer.OrdinalIgnoreCase); + + public ModImportService(GitHubService github, CvrmgService cvrmg) + { + this.github = github; + this.cvrmg = cvrmg; + } + + public static string Norm(string s) => CvrmgService.Normalize(s); + + public async Task> IdentifyAsync(string gameDir, IEnumerable sources, Action log, CancellationToken ct) + { + var files = new List<(AssemblyDetails Details, string Folder)>(); + foreach (var folder in Folders) + { + var dir = Path.Combine(gameDir, folder); + if (!Directory.Exists(dir)) continue; + foreach (var f in Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly).OrderBy(f => f, StringComparer.OrdinalIgnoreCase)) + files.Add((AssemblyReader.Read(f), folder)); + foreach (var sub in Directory.EnumerateDirectories(dir)) + log($"Skipping folder {folder}\\{Path.GetFileName(sub)} (only loose DLLs are imported)."); + } + log($"Found {files.Count} mod files."); + + List catalog = new(); + try { catalog = await cvrmg.GetAllAsync(ct); } + catch (Exception e) when (e is not OperationCanceledException) { log($"CVRMG unavailable ({e.Message}); matching without it."); } + + var index = new Dictionary>(); + foreach (var src in sources.Where(s => s.Kind == "github" && s.Repo.Length > 0)) + { + var releases = await ReleasesAsync(src.Repo, log, ct); + foreach (var r in releases) + foreach (var a in r.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))) + { + var key = Norm(a.Name); + if (!index.TryGetValue(key, out var l)) index[key] = l = new(); + if (l.All(x => !x.Repo.Equals(src.Repo, StringComparison.OrdinalIgnoreCase))) l.Add((src.Repo, a.Name)); + } + } + + var result = new List(); + foreach (var (d, folder) in files) + { + ct.ThrowIfCancellationRequested(); + var cm = catalog.FirstOrDefault(m => m.Hash.Length > 0 && m.Hash == d.Sha256); + var cmWhy = cm != null ? "same file as the current CVRMG release" : ""; + if (cm == null) + { + var keys = new[] { d.MelonName, d.AssemblyName, Path.GetFileNameWithoutExtension(d.FileName) } + .Select(Norm).Where(k => k.Length > 0).Distinct().ToList(); + cm = catalog.Where(m => new[] { m.Name }.Concat(m.Aliases).Select(Norm).Any(keys.Contains)) + .OrderBy(m => m.ApprovalStatus == 1 ? 0 : 1).ThenByDescending(m => m.Updated).FirstOrDefault(); + if (cm != null) cmWhy = "name match in CVRMG"; + } + + string? repo = null, asset = null, why = ""; + var link = GitHubService.ParseRepo(d.MelonLink.Contains("github.com", StringComparison.OrdinalIgnoreCase) ? d.MelonLink : ""); + if (link != null) + { + var rel = await ReleasesAsync(link, log, ct); + var hit = rel.SelectMany(r => r.Assets).FirstOrDefault(a => Norm(a.Name) == Norm(d.FileName)); + if (hit != null) { repo = link; asset = hit.Name; why = "download link in the mod's own metadata"; } + } + if (repo == null && index.TryGetValue(Norm(d.FileName), out var cands)) + { + var pick = cands.FirstOrDefault(c => d.MelonAuthor.Length > 0 && c.Repo.Split('/')[0].Equals(d.MelonAuthor, StringComparison.OrdinalIgnoreCase)); + if (pick == default) pick = cands[0]; + repo = pick.Repo; + asset = pick.Asset; + why = "release assets of " + repo; + } + + if (repo != null) result.Add(new Identified(d, folder, "github", repo, asset!, cm, why)); + else if (cm != null) result.Add(new Identified(d, folder, "cvrmg", "", "", cm, cmWhy)); + else result.Add(new Identified(d, folder, "local", "", "", null, + d.MelonLink.Length > 0 ? $"no published release contains it; its metadata points at {d.MelonLink}" : "no known source")); + } + return result; + } + + public void Invalidate() => repoReleases.Clear(); + + public async Task> ReleasesAsync(string repo, Action log, CancellationToken ct, bool force = false) + { + if (!force && repoReleases.TryGetValue(repo, out var cached)) return cached; + var path = Path.Combine(Paths.Root, "sources", "gh_" + Paths.Safe(repo.Replace('/', '_')) + ".json"); + try + { + if (!force && File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < TimeSpan.FromHours(1)) + { + var fromDisk = System.Text.Json.JsonSerializer.Deserialize>(await File.ReadAllTextAsync(path, ct)); + if (fromDisk != null) return repoReleases[repo] = fromDisk; + } + var list = await github.GetReleasesAsync(repo, ct); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(list), ct); + return repoReleases[repo] = list; + } + catch (Exception e) when (e is not OperationCanceledException) + { + if (File.Exists(path)) + { + try + { + var stale = System.Text.Json.JsonSerializer.Deserialize>(await File.ReadAllTextAsync(path, ct)); + if (stale != null) + { + log($"{repo}: using cached releases ({e.Message})"); + return repoReleases[repo] = stale; + } + } + catch (System.Text.Json.JsonException) { } + } + log($"{repo}: {e.Message}"); + return repoReleases[repo] = new(); + } + } + + public static string SnapshotTag(AssemblyDetails d) => + $"installed {d.Version} ({Convert.ToHexString(Convert.FromBase64String(d.Sha256))[..6].ToLowerInvariant()})"; + + public int Apply(AppConfig cfg, InstallInfo? install, IEnumerable found, Action log, string? gameDir = null) + { + var game = gameDir ?? Paths.GameDir(install!); + var added = 0; + foreach (var it in found) + { + var rel = $"{it.Folder}/{it.File.FileName}"; + if (install != null && install.Deployed.Values.Any(l => l.Contains(rel, StringComparer.OrdinalIgnoreCase))) continue; + + var def = FindOrCreate(cfg, it); + var tag = SnapshotTag(it.File); + if (def.Releases.All(r => r.Tag != tag)) + def.Releases.Add(new ReleaseInfo { Tag = tag, Snapshot = true, Published = File.GetLastWriteTime(Path.Combine(game, it.Folder, it.File.FileName)) }); + + var filesDir = Paths.ModFilesDir(def, tag); + if (!ModStore.IsDownloaded(def, tag)) + { + Directory.CreateDirectory(filesDir); + File.Copy(Path.Combine(game, it.Folder, it.File.FileName), Path.Combine(filesDir, it.File.FileName), true); + File.WriteAllText(Path.Combine(Paths.ModVersionDir(def, tag), ".complete"), ""); + } + + if (install != null) + { + install.Mods.RemoveAll(a => a.ModId == def.Id); + install.Mods.Add(new ModAssignment { ModId = def.Id, Version = tag, Enabled = true }); + install.Deployed[def.Id] = ModStore.MapFiles(def, tag).Keys.ToList(); + } + added++; + log($"{def.Name} {it.File.Version}: {Describe(it)}"); + } + return added; + } + + static string Describe(Identified it) => it.Source switch + { + "github" => $"GitHub {it.Repo} ({it.Why})", + "cvrmg" => $"CVRMG ({it.Why})", + _ => $"kept as a local snapshot ({it.Why})", + }; + + ModDef FindOrCreate(AppConfig cfg, Identified it) + { + ModDef? def = it.Source switch + { + "github" => cfg.Mods.FirstOrDefault(m => m.Source == "github" && m.Repo.Equals(it.Repo, StringComparison.OrdinalIgnoreCase) && Norm(m.AssetFilter) == Norm(it.AssetName)), + "cvrmg" => cfg.Mods.FirstOrDefault(m => m.CvrmgId == it.Cvrmg!.Id && m.Source == "cvrmg"), + _ => cfg.Mods.FirstOrDefault(m => m.Local && Norm(m.FileName) == Norm(it.File.FileName)), + }; + if (def != null) return def; + + def = new ModDef + { + Name = it.Cvrmg?.Name is { Length: > 0 } n ? n : it.File.MelonName.Length > 0 ? it.File.MelonName : it.File.AssemblyName, + Type = it.Folder, + FileName = it.File.FileName, + Author = it.File.MelonAuthor, + Source = it.Source, + Local = it.Source == "local", + }; + if (it.Cvrmg != null) CvrmgService.ApplyMeta(def, it.Cvrmg); + if (def.SourceLink.Length == 0 && it.File.MelonLink.Length > 0) def.SourceLink = it.File.MelonLink; + switch (it.Source) + { + case "github": + def.Repo = it.Repo; + def.AssetFilter = it.AssetName; + def.SourceLink = $"https://github.com/{it.Repo}"; + def.Releases = FilterReleases(repoReleases.GetValueOrDefault(it.Repo) ?? new(), def.AssetFilter); + break; + case "cvrmg": + def.Releases = new() { CvrmgService.ToRelease(it.Cvrmg!, it.File.FileName) }; + break; + } + cfg.Mods.Add(def); + return def; + } + + public static List FilterReleases(IEnumerable releases, string assetFilter) => + string.IsNullOrWhiteSpace(assetFilter) + ? releases.ToList() + : releases.Where(r => r.Assets.Any(a => ModStore.AssetMatches(a.Name, assetFilter))).ToList(); +} diff --git a/Services/ModStore.cs b/Services/ModStore.cs new file mode 100644 index 0000000..0f81254 --- /dev/null +++ b/Services/ModStore.cs @@ -0,0 +1,183 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using CVRManager.Models; + +namespace CVRManager.Services; + +public class ModStore +{ + static readonly string[] KnownRoots = { "Mods", "Plugins", "UserLibs", "UserData", "MelonLoader" }; + const string Marker = ".complete"; + + readonly GitHubService github; + + public ModStore(GitHubService github) => this.github = github; + + public static string ResolveTag(ModDef mod, string version) => + version == "latest" ? mod.LatestTag ?? "" : version; + + public static bool IsDownloaded(ModDef mod, string tag) => + tag.Length > 0 && File.Exists(Path.Combine(Paths.ModVersionDir(mod, tag), Marker)); + + public async Task EnsureDownloadedAsync(ModDef mod, string tag, Action log, CancellationToken ct) + { + if (IsDownloaded(mod, tag)) return; + if (mod.Local) throw new InvalidOperationException($"Local files for {mod.Name} are missing from the store."); + + var rel = mod.Releases.FirstOrDefault(r => r.Tag == tag) + ?? throw new InvalidOperationException($"{mod.Name} has no release {tag}; refresh versions."); + var assets = PickAssets(mod, rel); + if (assets.Count == 0) + throw new InvalidOperationException( + $"{mod.Name} {tag}: no downloadable .dll or .zip asset matched. Assets: {string.Join(", ", rel.Assets.Select(a => a.Name))}. Set an asset filter on the mod."); + + var dir = Paths.ModVersionDir(mod, tag); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + var files = Paths.ModFilesDir(mod, tag); + Directory.CreateDirectory(files); + + foreach (var a in assets) + { + log($"Downloading {a.Name} ({a.Size / 1024} KB)..."); + var tmp = Path.Combine(dir, Paths.Safe(a.Name)); + var served = await github.DownloadAsync(a.Url, tmp, ct); + if (a.Hash.Length > 0) + { + var actual = Convert.ToBase64String(SHA256.HashData(await File.ReadAllBytesAsync(tmp, ct))); + if (actual != a.Hash) + { + File.Delete(tmp); + throw new InvalidOperationException($"{a.Name}: the downloaded file does not match the hash its source published."); + } + } + var isZip = a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) || served.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + if (isZip) + { + ZipFile.ExtractToDirectory(tmp, files, true); + File.Delete(tmp); + } + else + { + var name = mod.Source == "cvrmg" && served.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? served : a.Name; + File.Move(tmp, Path.Combine(files, Paths.Safe(name)), true); + } + } + File.WriteAllText(Path.Combine(dir, Marker), ""); + } + + static List PickAssets(ModDef mod, ReleaseInfo rel) + { + if (mod.Source == "cvrmg") return rel.Assets.ToList(); + if (!string.IsNullOrWhiteSpace(mod.AssetFilter)) + { + return rel.Assets.Where(a => AssetMatches(a.Name, mod.AssetFilter)).ToList(); + } + var dlls = rel.Assets.Where(a => a.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)).ToList(); + if (dlls.Count > 0) return dlls; + return rel.Assets.Where(a => a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)).Take(1).ToList(); + } + + public static bool AssetMatches(string assetName, string filter) + { + foreach (var f in filter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (f.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + if (CvrmgService.Normalize(assetName) == CvrmgService.Normalize(f)) return true; + } + else if (assetName.Contains(f, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + public void ImportLocal(ModDef mod, IEnumerable paths) + { + var dir = Paths.ModVersionDir(mod, "local"); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + var files = Paths.ModFilesDir(mod, "local"); + Directory.CreateDirectory(files); + foreach (var p in paths) + { + if (p.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) ZipFile.ExtractToDirectory(p, files, true); + else File.Copy(p, Path.Combine(files, Path.GetFileName(p)), true); + } + File.WriteAllText(Path.Combine(dir, Marker), ""); + } + + public static void DeleteVersion(ModDef mod, string tag) + { + var dir = Paths.ModVersionDir(mod, tag); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + } + + public static Dictionary MapFiles(ModDef mod, string tag) + { + var root = Paths.ModFilesDir(mod, tag); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(root)) return map; + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(root, file).Replace('\\', '/'); + map[Target(mod, rel)] = file; + } + return map; + } + + static string Target(ModDef mod, string rel) + { + if (mod.Type == "Root") return rel; + var first = rel.Split('/')[0]; + if (rel.Contains('/') && KnownRoots.Any(k => k.Equals(first, StringComparison.OrdinalIgnoreCase))) return rel; + if (rel.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) return $"{mod.Type}/{Path.GetFileName(rel)}"; + return $"{mod.Type}/{rel}"; + } + + public async Task ApplyAsync(InstallInfo install, IReadOnlyList catalog, Action log, CancellationToken ct) + { + var game = Paths.GameDir(install); + if (!Directory.Exists(game)) throw new InvalidOperationException("The install has no game files yet. Update it first."); + + var desired = new Dictionary>(); + foreach (var a in install.Mods.Where(a => a.Enabled)) + { + var mod = catalog.FirstOrDefault(m => m.Id == a.ModId); + if (mod == null) continue; + var tag = ResolveTag(mod, a.Version); + if (tag.Length == 0) throw new InvalidOperationException($"{mod.Name} has no versions; refresh versions in the Mods tab."); + await EnsureDownloadedAsync(mod, tag, log, ct); + desired[mod.Id] = MapFiles(mod, tag); + } + + foreach (var modId in install.Deployed.Keys.ToList()) + { + var keep = desired.TryGetValue(modId, out var map) ? map.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase) : new HashSet(); + foreach (var rel in install.Deployed[modId].Where(r => !keep.Contains(r))) + DeleteTracked(game, rel); + if (!desired.ContainsKey(modId)) install.Deployed.Remove(modId); + } + + foreach (var (modId, map) in desired) + { + foreach (var (rel, src) in map) + { + var dest = Path.Combine(game, rel.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.Copy(src, dest, true); + } + install.Deployed[modId] = map.Keys.ToList(); + log($"Deployed {catalog.First(m => m.Id == modId).Name} ({map.Count} files)"); + } + } + + static void DeleteTracked(string game, string rel) + { + var path = Path.Combine(game, rel.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(path)) File.Delete(path); + var dir = Path.GetDirectoryName(path); + while (!string.IsNullOrEmpty(dir) && dir.Length > game.Length && Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) + { + Directory.Delete(dir); + dir = Path.GetDirectoryName(dir); + } + } +} diff --git a/Services/MultiInstance.cs b/Services/MultiInstance.cs new file mode 100644 index 0000000..7e3f2fd --- /dev/null +++ b/Services/MultiInstance.cs @@ -0,0 +1,48 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace CVRManager.Services; + +public static class MultiInstance +{ + const int KeepLogs = 7; + + static readonly Regex SingleInstanceLine = new(@"^single-instance[^\r\n]*(\r?\n|$)", RegexOptions.Multiline | RegexOptions.Compiled); + + public static void Prepare(string gameDir, Action log) + { + RemoveSingleInstanceFlag(gameDir, log); + PruneMelonLoaderLogs(gameDir, log); + } + + static void RemoveSingleInstanceFlag(string gameDir, Action log) + { + var boot = Path.Combine(gameDir, "ChilloutVR_Data", "boot.config"); + if (!File.Exists(boot)) return; + var text = File.ReadAllText(boot); + var cleaned = SingleInstanceLine.Replace(text, ""); + if (cleaned == text) return; + File.WriteAllText(boot, cleaned, new UTF8Encoding(false)); + log("Removed 'single-instance' from boot.config so more than one instance can run."); + } + + static void PruneMelonLoaderLogs(string gameDir, Action log) + { + var logs = Path.Combine(gameDir, "MelonLoader", "Logs"); + if (!Directory.Exists(logs)) return; + var files = new DirectoryInfo(logs).GetFiles("*.log").OrderBy(f => f.LastWriteTimeUtc).ToList(); + var excess = files.Count - KeepLogs; + var removed = 0; + foreach (var f in files) + { + if (removed >= excess) break; + try + { + f.Delete(); + removed++; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { } + } + if (removed > 0) log($"Removed {removed} old MelonLoader log(s) so a second instance does not trip MelonLoader's log cleanup."); + } +} diff --git a/Services/Paths.cs b/Services/Paths.cs new file mode 100644 index 0000000..d7aba3d --- /dev/null +++ b/Services/Paths.cs @@ -0,0 +1,59 @@ +using CVRManager.Models; + +namespace CVRManager.Services; + +public static class Paths +{ + public const int AppId = 661130; + + public static string ConfigDir => Environment.GetEnvironmentVariable("CVRMANAGER_CONFIG_DIR") is { Length: > 0 } custom + ? custom + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "CVRManager"); + public static string ConfigFile => Path.Combine(ConfigDir, "config.json"); + + public static string Root { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CVRManager"); + + public static string SharedCache => Path.Combine(Root, "cache"); + public static string ModStore => Path.Combine(Root, "mods"); + public static string BranchCache => Path.Combine(Root, "sources", "branches.json"); + + public static string InstallsRoot => Path.Combine(Root, "installs"); + public static string InstallDir(InstallInfo i) => Path.Combine(InstallsRoot, i.Folder); + public static string GameDir(InstallInfo i) => InstallDir(i); + + public static string SafeFolderName(string name) + { + var bad = Path.GetInvalidFileNameChars(); + var cleaned = new string(name.Select(c => bad.Contains(c) ? ' ' : c).ToArray()); + while (cleaned.Contains(" ")) cleaned = cleaned.Replace(" ", " "); + cleaned = cleaned.Trim().TrimEnd('.'); + if (cleaned.Length > 60) cleaned = cleaned[..60].TrimEnd(); + return cleaned.Length == 0 ? "Install" : cleaned; + } + public static string GameDataDir(string? gameDir = null) + { + string company = "ChilloutVR", product = "ChilloutVR"; + try + { + var info = gameDir == null ? null : Path.Combine(gameDir, "ChilloutVR_Data", "app.info"); + if (info != null && File.Exists(info) && File.ReadAllLines(info) is { Length: >= 2 } l && l[0].Trim().Length > 0 && l[1].Trim().Length > 0) + (company, product) = (l[0].Trim(), l[1].Trim()); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { } + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "AppData", "LocalLow", company, product); + } + + public static string CacheDir(InstallInfo i) => Path.Combine(GameDir(i), "ChilloutVR_Data", "Cache"); + public static string GameExe(InstallInfo i) => Path.Combine(GameDir(i), "ChilloutVR.exe"); + public static string Manifest(InstallInfo i) => Path.Combine(GameDir(i), "steamapps", $"appmanifest_{AppId}.acf"); + + public static string ModVersionDir(ModDef m, string tag) => Path.Combine(ModStore, m.Id, Safe(tag)); + public static string ModFilesDir(ModDef m, string tag) => Path.Combine(ModVersionDir(m, tag), "files"); + + public static string Safe(string s) + { + var bad = Path.GetInvalidFileNameChars(); + var r = new string(s.Select(c => bad.Contains(c) ? '_' : c).ToArray()).Trim().TrimEnd('.'); + return r.Length == 0 ? "_" : r; + } +} diff --git a/Services/SteamDownloader.cs b/Services/SteamDownloader.cs new file mode 100644 index 0000000..53d3fce --- /dev/null +++ b/Services/SteamDownloader.cs @@ -0,0 +1,399 @@ +// The depot and chunk download flow follows DepotDownloader (https://github.com/SteamRE/DepotDownloader, +// commit 989f37b, GPL-2.0, (c) SteamRE). This file is therefore under GPL-2.0. + +using System.Buffers; +using System.Collections.Concurrent; +using System.Net; +using System.Text.Json; +using CVRManager.Models; +using SteamKit2; +using SteamKit2.CDN; + +namespace CVRManager.Services; + +public static class SteamDownloader +{ + const uint AppId = (uint)Paths.AppId; + const int MaxDownloads = 8; + const string StateFolder = ".CVRManager"; + + public sealed record PrivateBranch(string Name, string Code, byte[]? Key); + + const int MaxCodeChecksPerRefresh = 3; + + public static async Task> GetBranchesAsync( + string account, string refreshToken, IReadOnlyList privateBranches, + Action keyResolved, Action log, CancellationToken ct) + { + await using var link = await SteamLink.OpenAsync(account, refreshToken, ct); + var (root, appToken) = await RequestAppInfoAsync(link); + var depots = root["depots"]; + var branches = depots["branches"]; + if (branches == KeyValue.Invalid) throw new InvalidOperationException("Steam returned no branch data for ChilloutVR."); + var list = branches.Children + .Select(b => new BranchInfo(b.Name ?? "", b["buildid"].Value ?? "", b["pwdrequired"].Value == "1", b["description"].Value ?? "")) + .ToList(); + + var known = list.Select(b => b.Name).ToHashSet(); + var keys = new Dictionary(); + var checks = 0; + var limited = false; + foreach (var pb in privateBranches.Where(p => p.Code.Length > 0 && !known.Contains(p.Name))) + { + ct.ThrowIfCancellationRequested(); + var key = pb.Key ?? keys.GetValueOrDefault(pb.Name); + if (key == null) + { + if (limited || checks >= MaxCodeChecksPerRefresh) continue; + checks++; + try + { + foreach (var (name, k) in await BranchCodeService.ResolveAsync(link, pb.Code)) + { + keys[name] = k; + keyResolved(name, k); + } + } + catch (InvalidOperationException e) + { + log($"Could not check the code for '{pb.Name}': {e.Message}"); + limited = e.Message.Contains("RateLimit"); + continue; + } + key = keys.GetValueOrDefault(pb.Name); + if (key == null) { log($"The saved code no longer unlocks '{pb.Name}'."); continue; } + } + + var priv = await link.Apps.PICSGetPrivateBeta(AppId, appToken, pb.Name, key); + if (priv.Result != EResult.OK) { log($"Steam would not list the files of '{pb.Name}' ({priv.Result})."); continue; } + var selection = SelectDepots(depots, priv.DepotSection, pb.Name); + if (selection.Count == 0) continue; + list.Add(new BranchInfo(pb.Name, PrivateMarker(selection), true, "")); + } + + return list.OrderBy(b => b.Name == "public" ? 0 : 1).ThenBy(b => b.Name).ToList(); + } + + static string PrivateMarker(IEnumerable<(uint DepotId, ulong ManifestId)> depots) + { + var text = string.Join(";", depots.OrderBy(d => d.DepotId).Select(d => $"{d.DepotId}:{d.ManifestId}")); + return Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(System.Text.Encoding.ASCII.GetBytes(text)))[..12].ToLowerInvariant(); + } + + static List<(uint DepotId, ulong ManifestId)> SelectDepots(KeyValue depotsSection, KeyValue? privateDepots, string branch) + { + var result = new List<(uint, ulong)>(); + foreach (var depot in depotsSection.Children) + { + if (!uint.TryParse(depot.Name, out var depotId) || depot.Children.Count == 0) continue; + var cfg = depot["config"]; + if (cfg != KeyValue.Invalid) + { + var os = cfg["oslist"].Value; + if (!string.IsNullOrWhiteSpace(os) && !os.Split(',').Contains("windows")) continue; + var arch = cfg["osarch"].Value; + if (!string.IsNullOrWhiteSpace(arch) && arch != "64") continue; + var lang = cfg["language"].Value; + if (!string.IsNullOrWhiteSpace(lang) && lang != "english") continue; + if (cfg["lowviolence"].AsBoolean()) continue; + } + + var gid = (privateDepots?[depot.Name!]["manifests"][branch]["gid"].Value) ?? depot["manifests"][branch]["gid"].Value; + if (gid == null && branch != "public") gid = depot["manifests"]["public"]["gid"].Value; + if (gid == null) continue; + result.Add((depotId, ulong.Parse(gid))); + } + return result.OrderBy(d => d.Item1).ToList(); + } + + static async Task<(KeyValue Root, ulong Token)> RequestAppInfoAsync(SteamLink link) + { + var tokens = await link.Apps.PICSGetAccessTokens(new List { AppId }, new List()); + tokens.AppTokens.TryGetValue(AppId, out var token); + var request = new SteamApps.PICSRequest(AppId) { AccessToken = token }; + var result = await link.Apps.PICSGetProductInfo(new List { request }, new List()); + var info = result.Results?.SelectMany(r => r.Apps).Where(a => a.Key == AppId).Select(a => a.Value).FirstOrDefault() + ?? throw new InvalidOperationException("Steam returned no app info for ChilloutVR."); + return (info.KeyValues, token); + } + + sealed record DepotPlan(uint DepotId, ulong ManifestId, byte[] Key); + + sealed class InstallState + { + public bool Complete { get; set; } + public Dictionary Manifests { get; set; } = new(); + } + + public static async Task InstallAsync( + string gameDir, string branch, string branchCode, byte[]? branchKey, Action keyResolved, + string account, string refreshToken, bool validate, Action log, Action progress, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(branch)) branch = "public"; + Directory.CreateDirectory(gameDir); + var stateDir = Path.Combine(gameDir, StateFolder); + Directory.CreateDirectory(stateDir); + + await using var link = await SteamLink.OpenAsync(account, refreshToken, ct); + log(link.IsAnonymous ? "Connected to Steam anonymously." : $"Signed in to Steam as {account}."); + + var (root, appToken) = await RequestAppInfoAsync(link); + var depotsSection = root["depots"]; + var branchNode = depotsSection["branches"][branch]; + var listed = branchNode != KeyValue.Invalid; + var buildId = listed ? branchNode["buildid"].Value ?? "" : ""; + + KeyValue? privateDepots = null; + if (!listed || branchNode["pwdrequired"].Value == "1") + { + if (string.IsNullOrEmpty(branchCode)) + throw new InvalidOperationException($"The branch '{branch}' is not public and needs its beta code. Enter the code from the branch list."); + if (branchKey == null) + { + var keys = await BranchCodeService.ResolveAsync(link, branchCode); + foreach (var (name, k) in keys) keyResolved(name, k); + if (!keys.TryGetValue(branch, out branchKey)) + throw new InvalidOperationException($"The saved code does not unlock '{branch}'. Enter the code again from the branch list."); + } + var priv = await link.Apps.PICSGetPrivateBeta(AppId, appToken, branch, branchKey); + if (priv.Result != EResult.OK) throw new InvalidOperationException($"Steam would not give out the files for '{branch}' ({priv.Result})."); + privateDepots = priv.DepotSection; + } + + var plans = new List(); + foreach (var (depotId, manifestId) in SelectDepots(depotsSection, privateDepots, branch)) + { + var keyReply = await link.Apps.GetDepotDecryptionKey(depotId, AppId); + if (keyReply.Result != EResult.OK) throw new InvalidOperationException($"Steam refused the key for depot {depotId} ({keyReply.Result})."); + plans.Add(new DepotPlan(depotId, manifestId, keyReply.DepotKey)); + } + if (plans.Count == 0) throw new InvalidOperationException("Steam listed no downloadable files for this branch."); + if (buildId.Length == 0) buildId = PrivateMarker(plans.Select(p => (p.DepotId, p.ManifestId))); + + var statePath = Path.Combine(stateDir, "state.json"); + var state = new InstallState(); + try { if (File.Exists(statePath)) state = JsonSerializer.Deserialize(File.ReadAllText(statePath)) ?? new(); } catch { } + var forceValidate = validate || !state.Complete; + state.Complete = false; + File.WriteAllText(statePath, JsonSerializer.Serialize(state)); + + var servers = await LoadServersAsync(link); + var cdn = new Client(link.Client); + var authTokens = new ConcurrentDictionary<(uint, string), string?>(); + var serverCursor = 0; + + Server NextServer() => servers[Math.Abs(Interlocked.Increment(ref serverCursor)) % servers.Count]; + + async Task TokenFor(uint depotId, Server server) + { + if (authTokens.TryGetValue((depotId, server.Host!), out var cached)) return cached; + var reply = await link.Content.GetCDNAuthToken(AppId, depotId, server.Host!); + var token = reply.Result == EResult.OK ? reply.Token : null; + authTokens[(depotId, server.Host!)] = token; + return token; + } + + var fullGameDir = Path.GetFullPath(gameDir) + Path.DirectorySeparatorChar; + string Target(string fileName) + { + var full = Path.GetFullPath(Path.Combine(gameDir, fileName)); + if (!full.StartsWith(fullGameDir, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"Refusing to write outside the install folder: {fileName}"); + return full; + } + + var newManifests = new Dictionary(); + var oldManifests = new Dictionary(); + foreach (var plan in plans) + { + ct.ThrowIfCancellationRequested(); + DepotManifest? old = null; + var oldFile = Path.Combine(stateDir, $"{plan.DepotId}.manifest"); + if (state.Manifests.ContainsKey(plan.DepotId.ToString()) && File.Exists(oldFile)) + try { old = DepotManifest.LoadFromFile(oldFile); } catch { } + oldManifests[plan.DepotId] = old; + + var manifest = old is { } o && o.ManifestGID == plan.ManifestId ? o : await DownloadManifestAsync(plan); + newManifests[plan.DepotId] = manifest; + log($"Depot {plan.DepotId}: {manifest.Files!.Count} files, {FileOps.FormatSize((long)manifest.TotalUncompressedSize)}."); + } + + async Task DownloadManifestAsync(DepotPlan plan) + { + for (var attempt = 0; attempt < 8; attempt++) + { + ct.ThrowIfCancellationRequested(); + var server = NextServer(); + try + { + var code = await link.Content.GetManifestRequestCode(plan.DepotId, AppId, plan.ManifestId, branch); + if (code == 0) throw new InvalidOperationException($"Steam gave no manifest code for depot {plan.DepotId}."); + return await cdn.DownloadManifestAsync(plan.DepotId, plan.ManifestId, code, server, plan.Key, null, authTokens.GetValueOrDefault((plan.DepotId, server.Host!))); + } + catch (SteamKitWebRequestException e) when (e.StatusCode == HttpStatusCode.Forbidden && !authTokens.ContainsKey((plan.DepotId, server.Host!))) + { + await TokenFor(plan.DepotId, server); + } + catch (Exception e) when (e is not OperationCanceledException && e is not InvalidOperationException) + { + log($"Manifest download from {server.Host} failed ({e.Message}); trying another server."); + } + } + throw new InvalidOperationException($"Could not download the file list for depot {plan.DepotId}."); + } + + var work = new List<(uint DepotId, byte[] Key, DepotManifest.FileData File, string Path, List Chunks)>(); + long totalFiles = newManifests.Values.Sum(m => m.Files!.Count(f => !f.Flags.HasFlag(EDepotFileFlag.Directory))); + long scanned = 0; + log(forceValidate ? "Checking every file against Steam..." : "Looking for changed files..."); + progress(0); + + var workLock = new object(); + foreach (var plan in plans) + { + var manifest = newManifests[plan.DepotId]; + var old = oldManifests[plan.DepotId]; + var oldByName = old?.Files!.ToDictionary(f => f.FileName, StringComparer.OrdinalIgnoreCase); + + foreach (var dir in manifest.Files!.Where(f => f.Flags.HasFlag(EDepotFileFlag.Directory))) + Directory.CreateDirectory(Target(dir.FileName)); + + await Parallel.ForEachAsync(manifest.Files!.Where(f => !f.Flags.HasFlag(EDepotFileFlag.Directory)), + new ParallelOptions { MaxDegreeOfParallelism = MaxDownloads, CancellationToken = ct }, + async (file, token) => + { + await Task.Yield(); + var path = Target(file.FileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var needed = NeededChunks(path, file, oldByName, forceValidate); + if (needed.Count > 0) lock (workLock) work.Add((plan.DepotId, plan.Key, file, path, needed)); + var done = Interlocked.Increment(ref scanned); + if (done % 25 == 0 || done == totalFiles) progress(done * 100.0 / totalFiles); + }); + } + + var totalBytes = work.Sum(w => w.Chunks.Sum(c => (long)c.UncompressedLength)); + if (work.Count == 0) log("Everything is already up to date."); + else + { + log($"Downloading {FileOps.FormatSize(totalBytes)} in {work.Count} file(s)..."); + progress(0); + long downloaded = 0; + var queue = work.SelectMany(w => w.Chunks.Select(c => (w.DepotId, w.Key, w.Path, Chunk: c))).ToList(); + await Parallel.ForEachAsync(queue, new ParallelOptions { MaxDegreeOfParallelism = MaxDownloads, CancellationToken = ct }, async (item, token) => + { + var buffer = ArrayPool.Shared.Rent((int)item.Chunk.UncompressedLength); + try + { + var written = 0; + for (var attempt = 0; attempt < 10 && written == 0; attempt++) + { + token.ThrowIfCancellationRequested(); + var server = NextServer(); + try + { + written = await cdn.DownloadDepotChunkAsync(item.DepotId, item.Chunk, server, buffer, item.Key, null, authTokens.GetValueOrDefault((item.DepotId, server.Host!))); + } + catch (SteamKitWebRequestException e) when (e.StatusCode == HttpStatusCode.Forbidden && !authTokens.ContainsKey((item.DepotId, server.Host!))) + { + await TokenFor(item.DepotId, server); + } + catch (Exception e) when (e is not OperationCanceledException) + { + if (attempt >= 4) log($"Chunk download from {server.Host} failed ({e.Message})."); + } + } + if (written == 0) throw new InvalidOperationException("No Steam download server could deliver a file piece. Try again in a minute."); + + await using var fs = new FileStream(item.Path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite); + fs.Seek((long)item.Chunk.Offset, SeekOrigin.Begin); + await fs.WriteAsync(buffer.AsMemory(0, written), token); + progress(Interlocked.Add(ref downloaded, written) * 100.0 / totalBytes); + } + finally { ArrayPool.Shared.Return(buffer); } + }); + } + + foreach (var plan in plans) + { + var old = oldManifests[plan.DepotId]; + if (old != null) + { + var keep = newManifests[plan.DepotId].Files!.Select(f => f.FileName).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var gone in old.Files!.Where(f => !f.Flags.HasFlag(EDepotFileFlag.Directory) && !keep.Contains(f.FileName))) + { + var path = Target(gone.FileName); + if (File.Exists(path)) { File.Delete(path); log($"Removed {gone.FileName}"); } + } + } + newManifests[plan.DepotId].SaveToFile(Path.Combine(stateDir, $"{plan.DepotId}.manifest")); + state.Manifests[plan.DepotId.ToString()] = plan.ManifestId; + } + state.Complete = true; + File.WriteAllText(statePath, JsonSerializer.Serialize(state)); + AppManifest.Write(gameDir, buildId, branch); + progress(100); + log($"Installed build {buildId} of '{branch}'."); + } + + static List NeededChunks( + string path, DepotManifest.FileData file, Dictionary? oldByName, bool forceValidate) + { + var chunks = file.Chunks!.OrderBy(c => c.Offset).ToList(); + var info = new FileInfo(path); + if (!info.Exists) + { + using var created = File.Create(path); + created.SetLength((long)file.TotalSize); + return chunks; + } + + if (!forceValidate && info.Length == (long)file.TotalSize + && oldByName != null && oldByName.TryGetValue(file.FileName, out var before) && before.FileHash.AsSpan().SequenceEqual(file.FileHash)) + return new(); + + using var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + if (fs.Length != (long)file.TotalSize) fs.SetLength((long)file.TotalSize); + var needed = new List(); + var buffer = ArrayPool.Shared.Rent(chunks.Count == 0 ? 1 : (int)chunks.Max(c => c.UncompressedLength)); + try + { + foreach (var chunk in chunks) + { + fs.Seek((long)chunk.Offset, SeekOrigin.Begin); + var length = (int)chunk.UncompressedLength; + var read = 0; + while (read < length) + { + var n = fs.Read(buffer, read, length - read); + if (n == 0) break; + read += n; + } + if (read < length || Adler(buffer, length) != chunk.Checksum) needed.Add(chunk); + } + } + finally { ArrayPool.Shared.Return(buffer); } + return needed; + } + + static uint Adler(byte[] data, int length) + { + uint a = 0, b = 0; + for (var i = 0; i < length; i++) + { + a = (a + data[i]) % 65521; + b = (b + a) % 65521; + } + return a | (b << 16); + } + + static async Task> LoadServersAsync(SteamLink link) + { + var all = await link.Content.GetServersForSteamPipe(); + var list = new List(); + foreach (var s in all.Where(s => (s.AllowedAppIds.Length == 0 || s.AllowedAppIds.Contains(AppId)) && (s.Type == "SteamCache" || s.Type == "CDN")) + .OrderBy(s => s.WeightedLoad)) + for (var i = 0; i < s.NumEntries; i++) list.Add(s); + if (list.Count == 0) throw new InvalidOperationException("Steam gave no download servers."); + return list; + } +} diff --git a/Services/SteamIntegration.cs b/Services/SteamIntegration.cs new file mode 100644 index 0000000..f388a68 --- /dev/null +++ b/Services/SteamIntegration.cs @@ -0,0 +1,73 @@ +using System.Diagnostics; +using Microsoft.Win32; + +namespace CVRManager.Services; + +public static class SteamIntegration +{ + public static string? SteamPath + { + get + { + var p = Registry.GetValue(@"HKEY_CURRENT_USER\Software\Valve\Steam", "SteamPath", null) as string; + return p == null ? null : Path.GetFullPath(p); + } + } + + public static string? SteamExe => SteamPath is { } p ? Path.Combine(p, "steam.exe") : null; + + public static bool IsRunning => Process.GetProcessesByName("steam").Length > 0; + + public static bool IsAppRunning => + Registry.GetValue($@"HKEY_CURRENT_USER\Software\Valve\Steam\Apps\{Paths.AppId}", "Running", 0) is 1; + + public static async Task EnsureRunningAsync(Action log) + { + if (IsRunning) return true; + var exe = SteamExe; + if (exe == null || !File.Exists(exe)) return false; + log("Steam is not running; starting it in the background so playtime is counted..."); + Process.Start(new ProcessStartInfo(exe, "-silent") { UseShellExecute = true }); + for (var i = 0; i < 40; i++) + { + await Task.Delay(500); + var pid = Registry.GetValue(@"HKEY_CURRENT_USER\Software\Valve\Steam\ActiveProcess", "pid", 0) as int? ?? 0; + if (pid != 0 && IsRunning) { await Task.Delay(2000); return true; } + } + return IsRunning; + } + + public static List<(string GameDir, string Manifest)> FindExistingInstalls() + { + var result = new List<(string, string)>(); + var steam = SteamPath; + if (steam == null) return result; + var libs = new List { steam }; + try + { + var vdf = Path.Combine(steam, "steamapps", "libraryfolders.vdf"); + if (File.Exists(vdf)) + { + var doc = Vdf.ParseDocument(File.ReadAllText(vdf)); + if (doc.Dict("libraryfolders") is { } lf) + foreach (var e in lf.Values.OfType>()) + if (e.Str("path") is { Length: > 0 } p) libs.Add(p); + } + } + catch { } + + foreach (var lib in libs.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var manifest = Path.Combine(lib, "steamapps", $"appmanifest_{Paths.AppId}.acf"); + if (!File.Exists(manifest)) continue; + try + { + var dir = Vdf.ParseDocument(File.ReadAllText(manifest)).Dict("AppState")?.Str("installdir") ?? "ChilloutVR"; + var game = Path.Combine(lib, "steamapps", "common", dir); + if (Directory.Exists(game)) result.Add((game, manifest)); + } + catch { } + } + return result; + } +} diff --git a/Services/SteamLink.cs b/Services/SteamLink.cs new file mode 100644 index 0000000..d473ff4 --- /dev/null +++ b/Services/SteamLink.cs @@ -0,0 +1,146 @@ +using SteamKit2; +using SteamKit2.Authentication; + +namespace CVRManager.Services; + +public sealed class SteamLink : IAsyncDisposable +{ + readonly CallbackManager manager; + readonly CancellationTokenSource stop = new(); + readonly TaskCompletionSource connected = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource? loggedOn; + Task pump = Task.CompletedTask; + + public SteamClient Client { get; } = new(); + public SteamUser User { get; } + public SteamApps Apps { get; } + public SteamContent Content { get; } + public bool IsAnonymous { get; private set; } + + readonly uint loginId = (uint)Random.Shared.NextInt64(0x10000, uint.MaxValue); + + SteamLink() + { + manager = new CallbackManager(Client); + User = Client.GetHandler()!; + Apps = Client.GetHandler()!; + Content = Client.GetHandler()!; + manager.Subscribe(_ => connected.TrySetResult()); + manager.Subscribe(_ => + { + var e = new InvalidOperationException("Lost the connection to Steam."); + connected.TrySetException(e); + loggedOn?.TrySetException(e); + }); + manager.Subscribe(cb => loggedOn?.TrySetResult(cb)); + } + + public static async Task ConnectAsync(CancellationToken ct) + { + var link = new SteamLink(); + link.pump = Task.Run(() => + { + while (!link.stop.IsCancellationRequested) link.manager.RunWaitCallbacks(TimeSpan.FromMilliseconds(200)); + }); + link.Client.Connect(); + try + { + await WaitAsync(link.connected.Task, TimeSpan.FromSeconds(20), "Could not reach Steam.", ct); + return link; + } + catch + { + await link.DisposeAsync(); + throw; + } + } + + public static async Task OpenAsync(string account, string refreshToken, CancellationToken ct) + { + var link = await ConnectAsync(ct); + try + { + if (string.IsNullOrEmpty(refreshToken)) await link.LogOnAnonymousAsync(ct); + else await link.LogOnAsync(account, refreshToken, ct); + return link; + } + catch + { + await link.DisposeAsync(); + throw; + } + } + + public async Task LogOnAnonymousAsync(CancellationToken ct) + { + IsAnonymous = true; + loggedOn = new(TaskCreationOptions.RunContinuationsAsynchronously); + User.LogOnAnonymous(); + Check(await WaitAsync(loggedOn.Task, TimeSpan.FromSeconds(20), "Steam did not answer the login.", ct)); + } + + public async Task LogOnAsync(string account, string refreshToken, CancellationToken ct) + { + loggedOn = new(TaskCreationOptions.RunContinuationsAsynchronously); + User.LogOn(new SteamUser.LogOnDetails { Username = account, AccessToken = refreshToken, ShouldRememberPassword = true, LoginID = loginId }); + Check(await WaitAsync(loggedOn.Task, TimeSpan.FromSeconds(20), "Steam did not answer the login.", ct)); + } + + static void Check(SteamUser.LoggedOnCallback cb) + { + if (cb.Result != EResult.OK) + throw new InvalidOperationException(cb.Result is EResult.AccessDenied or EResult.InvalidPassword or EResult.Expired or EResult.LoggedInElsewhere + ? $"Steam refused the saved sign-in ({cb.Result}); sign in again." + : $"Steam login failed: {cb.Result}"); + } + + public static async Task SignInAsync( + string account, string password, Action onDeviceConfirmation, Func> deviceCode, Func> emailCode, CancellationToken ct) + { + await using var link = await ConnectAsync(ct); + var session = await link.Client.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails + { + Username = account, + Password = password, + IsPersistentSession = true, + DeviceFriendlyName = "CVRManager", + Authenticator = new Authenticator(onDeviceConfirmation, deviceCode, emailCode), + }); + var result = await session.PollingWaitForResultAsync(ct); + return result.RefreshToken; + } + + sealed class Authenticator(Action confirmation, Func> device, Func> email) : IAuthenticator + { + public Task GetDeviceCodeAsync(bool previousCodeWasIncorrect) => device(previousCodeWasIncorrect); + public Task GetEmailCodeAsync(string emailAddress, bool previousCodeWasIncorrect) => email(emailAddress, previousCodeWasIncorrect); + + public Task AcceptDeviceConfirmationAsync() + { + confirmation(); + return Task.FromResult(true); + } + } + + static async Task WaitAsync(Task task, TimeSpan timeout, string message, CancellationToken ct) + { + var delay = Task.Delay(timeout, ct); + if (await Task.WhenAny(task, delay) == delay) throw new TimeoutException(message); + return await task; + } + + static async Task WaitAsync(Task task, TimeSpan timeout, string message, CancellationToken ct) + { + var delay = Task.Delay(timeout, ct); + if (await Task.WhenAny(task, delay) == delay) throw new TimeoutException(message); + await task; + } + + public async ValueTask DisposeAsync() + { + stop.Cancel(); + try { Client.Disconnect(); } catch { } + await pump; + stop.Dispose(); + } +} diff --git a/Services/ThemeService.cs b/Services/ThemeService.cs new file mode 100644 index 0000000..7b2b8e6 --- /dev/null +++ b/Services/ThemeService.cs @@ -0,0 +1,196 @@ +using System.Globalization; +using System.Text.Json; +using Avalonia; +using Avalonia.Media; + +namespace CVRManager.Services; + +public sealed record ThemePreset(string Name, Color Primary, Color Secondary); + +public static class ThemeService +{ + public const string DefaultName = "Default"; + public const string GameColoursName = "My in-game colours"; + + static Color F(double r, double g, double b) => Color.FromRgb((byte)Math.Round(r * 255), (byte)Math.Round(g * 255), (byte)Math.Round(b * 255)); + + public static IReadOnlyList Presets { get; } = new[] + { + new ThemePreset(DefaultName, Color.Parse("#5B8CFF"), Color.Parse("#7AA2FF")), + new ThemePreset("ChilloutVR", F(0.894, 0.212, 0), F(1, 0.992, 0.988)), + new ThemePreset("Ocean Mist", F(0, 0.5, 0.7), F(0.4, 0.8, 1)), + new ThemePreset("Sunset Glow", F(1, 0.4, 0.3), F(1, 0.6, 0.4)), + new ThemePreset("Forest Whisper", F(0.2, 0.6, 0.2), F(0.5, 0.8, 0.5)), + new ThemePreset("Royal Dusk", F(0.5, 0.2, 0.7), F(0.7, 0.4, 1)), + new ThemePreset("Desert Bloom", F(1, 0.7, 0.3), F(1, 0.9, 0.5)), + new ThemePreset("Blossom Veil", F(1, 0.5, 0.7), F(1, 0.7, 0.9)), + new ThemePreset("Titanium White", F(1, 1, 1), F(1, 1, 1)), + new ThemePreset("Where Is My Shader", F(1, 0, 1), F(0, 1, 1)), + new ThemePreset("Cotton Candy", F(0.357, 0.812, 0.98), F(0.961, 0.671, 0.725)), + new ThemePreset("Basis", F(0.063, 0.059, 0.153), F(0.91, 0.071, 0.212)), + new ThemePreset("Unit 01", F(0.435, 0.098, 0.522), F(0.345, 0.722, 0.388)), + new ThemePreset("Spooky", F(1, 0.404, 0), F(0.545, 0, 0.545)), + new ThemePreset("Cherry", F(0.824, 0.129, 0.204), F(0.278, 0.478, 0)), + new ThemePreset("Bongo", F(0.616, 0.035, 0.906), F(0.498, 0, 0.078)), + new ThemePreset("Kaffee", F(0.482, 0.259, 0.255), F(0.843, 0.647, 0.545)), + }; + + public static IEnumerable Names => new[] { GameColoursName }.Concat(Presets.Select(p => p.Name)); + + static string GameConfigPath => Path.Combine(Paths.GameDataDir(), "game.config"); + + public static (Color Primary, Color Secondary)? ReadGameColours() + { + try + { + if (!File.Exists(GameConfigPath)) return null; + using var doc = JsonDocument.Parse(File.ReadAllText(GameConfigPath)); + var values = new Dictionary(); + foreach (var e in doc.RootElement.GetProperty("settings").EnumerateArray()) + { + var name = e.GetProperty("name").GetString() ?? ""; + if (!name.StartsWith("PlayerColorPrimary", StringComparison.Ordinal) && !name.StartsWith("PlayerColorSecondary", StringComparison.Ordinal)) continue; + if (double.TryParse(e.GetProperty("value").GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) values[name] = v; + } + Color? Read(string key) + { + if (!values.TryGetValue(key + "R", out var r) || !values.TryGetValue(key + "G", out var g) || !values.TryGetValue(key + "B", out var b)) return null; + var scale = r > 1 || g > 1 || b > 1 ? 1.0 : 255.0; + byte B(double x) => (byte)Math.Clamp(Math.Round(x * scale), 0, 255); + return Color.FromRgb(B(r), B(g), B(b)); + } + var p = Read("PlayerColorPrimary"); + var s = Read("PlayerColorSecondary"); + return p is { } pc && s is { } sc ? (pc, sc) : null; + } + catch { return null; } + } + + public static bool Apply(string name) + { + Color primary, secondary; + var ok = true; + if (name == GameColoursName) + { + if (ReadGameColours() is { } g) (primary, secondary) = g; + else + { + ok = false; + (primary, secondary) = (Presets[1].Primary, Presets[1].Secondary); + } + } + else + { + var preset = Presets.FirstOrDefault(p => p.Name == name) ?? Presets[0]; + (primary, secondary) = (preset.Primary, preset.Secondary); + } + Set(primary, secondary); + return ok; + } + + static (double H, double S, double L) ToHsl(Color c) + { + double r = c.R / 255.0, g = c.G / 255.0, b = c.B / 255.0; + var max = Math.Max(r, Math.Max(g, b)); + var min = Math.Min(r, Math.Min(g, b)); + var l = (max + min) / 2; + if (max == min) return (0, 0, l); + var d = max - min; + var s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + double h; + if (max == r) h = (g - b) / d + (g < b ? 6 : 0); + else if (max == g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + return (h * 60, s, l); + } + + static Color FromHsl(double h, double s, double l) + { + h = ((h % 360) + 360) % 360; + s = Math.Clamp(s, 0, 1); + l = Math.Clamp(l, 0, 1); + var c = (1 - Math.Abs(2 * l - 1)) * s; + var x = c * (1 - Math.Abs(h / 60 % 2 - 1)); + var m = l - c / 2; + (double r, double g, double b) = (int)(h / 60) switch + { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + return Color.FromRgb((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255)); + } + + static double Luma(Color c) => (0.2126 * c.R + 0.7152 * c.G + 0.0722 * c.B) / 255.0; + + static Color PickAccent(Color primary, Color secondary) + { + bool Usable(Color c) { var (_, s, l) = ToHsl(c); return Luma(c) >= 0.18 && (s >= 0.25 || l >= 0.8); } + if (Usable(primary)) return primary; + if (Usable(secondary)) return secondary; + return Luma(primary) >= Luma(secondary) ? primary : secondary; + } + + static void Set(Color primary, Color secondary) + { + var app = Application.Current; + if (app == null) return; + var accent = PickAccent(primary, secondary); + var (h, s, l) = ToHsl(accent); + var accentL = Math.Clamp(l, 0.50, 0.68); + var accentS = s < 0.1 ? s : Math.Max(s, 0.45); + var accentColor = FromHsl(h, accentS, accentL); + var tint = Math.Min(s, 0.32); + var dimS = Math.Min(s, 0.14); + + void Brush(string key, Color c) => app.Resources[key] = new SolidColorBrush(c); + void Surface(string key, double lightness, double extraSat = 0) => Brush(key, FromHsl(h, Math.Min(tint + extraSat, 0.5), lightness)); + + app.Resources["SystemAccentColor"] = accentColor; + app.Resources["SystemAccentColorLight1"] = FromHsl(h, accentS, Math.Min(accentL + 0.08, 0.85)); + app.Resources["SystemAccentColorLight2"] = FromHsl(h, accentS, Math.Min(accentL + 0.16, 0.9)); + app.Resources["SystemAccentColorLight3"] = FromHsl(h, accentS, Math.Min(accentL + 0.24, 0.94)); + app.Resources["SystemAccentColorDark1"] = FromHsl(h, accentS, accentL - 0.08); + app.Resources["SystemAccentColorDark2"] = FromHsl(h, accentS, accentL - 0.16); + app.Resources["SystemAccentColorDark3"] = FromHsl(h, accentS, accentL - 0.24); + + Brush("AccentBrush", accentColor); + Brush("AccentHoverBrush", FromHsl(h, accentS, Math.Min(accentL + 0.08, 0.85))); + Brush("AccentPressedBrush", FromHsl(h, accentS, accentL - 0.08)); + Brush("AccentDisabledBrush", FromHsl(h, accentS * 0.4, 0.22)); + Brush("AccentFgBrush", Luma(accentColor) > 0.62 ? Color.Parse("#101114") : Colors.White); + + Surface("AppBg", 0.055); + Surface("SidebarBg", 0.04); + Surface("CardBg", 0.09); + Surface("CardBorder", 0.15); + Surface("RowHover", 0.125); + Surface("ChipBg", 0.16); + Surface("SelectedBg", 0.17, 0.08); + Surface("OverlayBg", 0.105); + Surface("ToastBg", 0.135); + Surface("GroupHeaderBg", 0.11); + Surface("GhostHover", 0.16); + Brush("DimText", FromHsl(h, dimS, 0.64)); + + Surface("ButtonBackground", 0.16); + Surface("ButtonBackgroundPointerOver", 0.2); + Surface("ButtonBackgroundPressed", 0.24); + Surface("ButtonBackgroundDisabled", 0.11); + Surface("TextControlBackground", 0.075); + Surface("TextControlBackgroundPointerOver", 0.095); + Surface("TextControlBackgroundFocused", 0.075); + Surface("TextControlBorderBrush", 0.18); + Surface("TextControlBorderBrushPointerOver", 0.25); + Brush("TextControlBorderBrushFocused", accentColor); + Surface("ComboBoxBackground", 0.075); + Surface("ComboBoxBackgroundPointerOver", 0.095); + Surface("ComboBoxBackgroundPressed", 0.095); + Surface("ComboBoxBorderBrush", 0.18); + Surface("ComboBoxBorderBrushPointerOver", 0.25); + Brush("ComboBoxBorderBrushPressed", accentColor); + } +} diff --git a/Services/TokenVault.cs b/Services/TokenVault.cs new file mode 100644 index 0000000..8be044e --- /dev/null +++ b/Services/TokenVault.cs @@ -0,0 +1,17 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CVRManager.Services; + +public static class TokenVault +{ + public static string Protect(string token) => + Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(token), null, DataProtectionScope.CurrentUser)); + + public static string Unprotect(string stored) + { + if (string.IsNullOrEmpty(stored)) return ""; + try { return Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(stored), null, DataProtectionScope.CurrentUser)); } + catch { return ""; } + } +} diff --git a/Services/UpdateService.cs b/Services/UpdateService.cs new file mode 100644 index 0000000..0824745 --- /dev/null +++ b/Services/UpdateService.cs @@ -0,0 +1,135 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; + +namespace CVRManager.Services; + +public record UpdateInfo(Version Version, string Tag, string ZipUrl, string HashUrl, long Size); + +public static class UpdateService +{ + public static string Host { get; } = Environment.GetEnvironmentVariable("CVRMANAGER_UPDATE_HOST") is { Length: > 0 } h ? h : "https://git.koneko.cat"; + public static string Repo { get; } = Environment.GetEnvironmentVariable("CVRMANAGER_UPDATE_REPO") is { Length: > 0 } r ? r : "Koneko/CVRManager"; + public const string AssetName = "CVRManager-win-x64.zip"; + + static readonly HttpClient Http = CreateClient(); + + static HttpClient CreateClient() + { + var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", Current.ToString(3))); + return http; + } + + public static Version Current { get; } = Normalize(Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(1, 0, 0)); + + static Version Normalize(Version v) => new(v.Major, Math.Max(v.Minor, 0), Math.Max(v.Build, 0)); + + public static bool TryParseTag(string tag, out Version version) + { + var ok = Version.TryParse(tag.TrimStart('v', 'V'), out var v); + version = ok ? Normalize(v!) : new Version(0, 0, 0); + return ok; + } + + public static async Task CheckAsync(CancellationToken ct) + { + using var resp = await Http.GetAsync($"{Host}/api/v1/repos/{Repo}/releases/latest", ct); + if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + resp.EnsureSuccessStatusCode(); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); + var root = doc.RootElement; + var tag = root.GetProperty("tag_name").GetString() ?? ""; + if (!TryParseTag(tag, out var version) || version <= Current) return null; + string? zip = null, hash = null; + long size = 0; + foreach (var a in root.GetProperty("assets").EnumerateArray()) + { + var name = a.GetProperty("name").GetString(); + var url = a.GetProperty("browser_download_url").GetString(); + if (name == AssetName) { zip = url; size = a.GetProperty("size").GetInt64(); } + else if (name == AssetName + ".sha256") hash = url; + } + if (zip == null || hash == null) return null; + return new UpdateInfo(version, tag, zip, hash, size); + } + + public static async Task DownloadAsync(UpdateInfo info, Action progress, CancellationToken ct) + { + var dir = Path.Combine(Paths.Root, "update", info.Tag); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + Directory.CreateDirectory(dir); + var zip = Path.Combine(dir, AssetName); + + var expected = (await Http.GetStringAsync(info.HashUrl, ct)).Trim().Split(' ', '\t')[0].ToLowerInvariant(); + using (var resp = await Http.GetAsync(info.ZipUrl, HttpCompletionOption.ResponseHeadersRead, ct)) + { + resp.EnsureSuccessStatusCode(); + var total = resp.Content.Headers.ContentLength ?? info.Size; + await using var src = await resp.Content.ReadAsStreamAsync(ct); + await using var dst = File.Create(zip); + var buffer = new byte[81920]; + long done = 0; + int n; + while ((n = await src.ReadAsync(buffer, ct)) > 0) + { + await dst.WriteAsync(buffer.AsMemory(0, n), ct); + done += n; + if (total > 0) progress(done * 100.0 / total); + } + } + + string actual; + await using (var fs = File.OpenRead(zip)) actual = Convert.ToHexString(await SHA256.HashDataAsync(fs, ct)).ToLowerInvariant(); + if (actual != expected) throw new InvalidDataException("The downloaded update is corrupted (checksum mismatch)."); + + var staged = Path.Combine(dir, "files"); + ZipFile.ExtractToDirectory(zip, staged); + File.Delete(zip); + if (!File.Exists(Path.Combine(staged, "CVRManager.exe"))) throw new InvalidDataException("The update package has no CVRManager.exe."); + return staged; + } + + public static void ApplyAndRestart(string staged) + { + var exe = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot find the running executable."); + var appDir = Path.GetDirectoryName(exe)!; + var script = Path.Combine(Path.GetDirectoryName(staged)!, "apply.ps1"); + File.WriteAllText(script, """ + param([int]$ProcessId, [string]$Source, [string]$Target, [string]$Exe) + $ErrorActionPreference = 'Stop' + try { Wait-Process -Id $ProcessId -Timeout 60 -ErrorAction SilentlyContinue } catch { } + $copied = $false + for ($i = 0; $i -lt 20 -and -not $copied; $i++) { + robocopy $Source $Target /E /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -lt 8) { $copied = $true } else { Start-Sleep -Milliseconds 500 } + } + if ($copied) { Remove-Item -LiteralPath $Source -Recurse -Force -ErrorAction SilentlyContinue } + Start-Process -FilePath $Exe -WorkingDirectory $Target + """); + var psi = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe")) + { + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + foreach (var a in new[] { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-ProcessId", Environment.ProcessId.ToString(), "-Source", staged, "-Target", appDir, "-Exe", exe }) + psi.ArgumentList.Add(a); + Process.Start(psi); + } + + public static bool CanWriteAppDir() + { + try + { + var probe = Path.Combine(AppContext.BaseDirectory, $".w{Environment.ProcessId}"); + File.WriteAllText(probe, ""); + File.Delete(probe); + return true; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return false; } + } +} diff --git a/Services/Vdf.cs b/Services/Vdf.cs new file mode 100644 index 0000000..073fa6e --- /dev/null +++ b/Services/Vdf.cs @@ -0,0 +1,99 @@ +using System.Text; + +namespace CVRManager.Services; + +public static class Vdf +{ + public static Dictionary Parse(string text, int start = 0) + { + var pos = start; + var root = new Dictionary(StringComparer.OrdinalIgnoreCase); + var key = ReadToken(text, ref pos); + if (key == null) return root; + var next = Peek(text, pos); + if (next == '{') + { + pos = text.IndexOf('{', pos) + 1; + root[key] = ParseBlock(text, ref pos); + } + return root; + } + + public static Dictionary ParseDocument(string text) + { + var pos = 0; + return ParseBlock(text, ref pos, true); + } + + static Dictionary ParseBlock(string text, ref int pos, bool toEnd = false) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + while (true) + { + SkipSpace(text, ref pos); + if (pos >= text.Length) return dict; + if (text[pos] == '}') + { + pos++; + return dict; + } + var key = ReadToken(text, ref pos); + if (key == null) return dict; + SkipSpace(text, ref pos); + if (pos < text.Length && text[pos] == '{') + { + pos++; + dict[key] = ParseBlock(text, ref pos); + } + else + { + var val = ReadToken(text, ref pos); + if (val == null) return dict; + dict[key] = val; + } + } + } + + static char Peek(string text, int pos) + { + SkipSpace(text, ref pos); + return pos < text.Length ? text[pos] : '\0'; + } + + static void SkipSpace(string text, ref int pos) + { + while (pos < text.Length) + { + if (char.IsWhiteSpace(text[pos])) pos++; + else if (text[pos] == '/' && pos + 1 < text.Length && text[pos + 1] == '/') + while (pos < text.Length && text[pos] != '\n') pos++; + else break; + } + } + + static string? ReadToken(string text, ref int pos) + { + SkipSpace(text, ref pos); + if (pos >= text.Length || text[pos] != '"') return null; + pos++; + var sb = new StringBuilder(); + while (pos < text.Length && text[pos] != '"') + { + if (text[pos] == '\\' && pos + 1 < text.Length) + { + pos++; + sb.Append(text[pos] switch { 'n' => '\n', 't' => '\t', var c => c }); + } + else sb.Append(text[pos]); + pos++; + } + pos++; + return sb.ToString(); + } + + public static Dictionary? Dict(this Dictionary d, string key) => + d.TryGetValue(key, out var v) ? v as Dictionary : null; + + public static string Str(this Dictionary d, string key) => + d.TryGetValue(key, out var v) && v is string s ? s : ""; +} diff --git a/ViewModels/Base.cs b/ViewModels/Base.cs new file mode 100644 index 0000000..beb03b0 --- /dev/null +++ b/ViewModels/Base.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; + +namespace CVRManager.ViewModels; + +public abstract class ObservableObject : INotifyPropertyChanged +{ + public event PropertyChangedEventHandler? PropertyChanged; + + protected bool Set(ref T field, T value, [CallerMemberName] string? name = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + return true; + } + + protected void Raise([CallerMemberName] string? name = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} + +public class RelayCommand : ICommand +{ + readonly Func run; + + public RelayCommand(Func run) => this.run = run; + public RelayCommand(Action run) => this.run = () => { run(); return Task.CompletedTask; }; + + public event EventHandler? CanExecuteChanged { add { } remove { } } + public bool CanExecute(object? parameter) => true; + public async void Execute(object? parameter) => await run(); +} diff --git a/ViewModels/GalleryViewModel.cs b/ViewModels/GalleryViewModel.cs new file mode 100644 index 0000000..e10f0a2 --- /dev/null +++ b/ViewModels/GalleryViewModel.cs @@ -0,0 +1,154 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Microsoft.VisualBasic.FileIO; + +namespace CVRManager.ViewModels; + +public class GalleryItemVM : ObservableObject +{ + Bitmap? thumb; + + public GalleryItemVM(GalleryViewModel owner, string path) + { + Path = path; + Name = System.IO.Path.GetFileName(path); + DateText = File.GetLastWriteTime(path).ToString("yyyy-MM-dd HH:mm"); + CopyCommand = new RelayCommand(() => owner.CopyAsync(this)); + DeleteCommand = new RelayCommand(() => owner.DeleteAsync(this)); + OpenCommand = new RelayCommand(() => owner.Open(this)); + ShowCommand = new RelayCommand(() => owner.Show(this)); + } + + public string Path { get; } + public string Name { get; } + public string DateText { get; } + public Bitmap? Thumb { get => thumb; set => Set(ref thumb, value); } + public ICommand CopyCommand { get; } + public ICommand DeleteCommand { get; } + public ICommand OpenCommand { get; } + public ICommand ShowCommand { get; } +} + +public class GalleryViewModel : ObservableObject +{ + const int PageSize = 48; + readonly MainViewModel main; + List all = new(); + int shown; + string status = ""; + bool loading; + + public GalleryViewModel(MainViewModel main) + { + this.main = main; + RefreshCommand = new RelayCommand(RefreshAsync); + MoreCommand = new RelayCommand(LoadMore); + OpenFolderCommand = new RelayCommand(() => + { + Directory.CreateDirectory(Root); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", $"\"{Root}\"") { UseShellExecute = true }); + }); + } + + public static string Root => System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures), "ChilloutVR"); + + public ObservableCollection Items { get; } = new(); + public ICommand RefreshCommand { get; } + public ICommand MoreCommand { get; } + public ICommand OpenFolderCommand { get; } + public string Status { get => status; private set => Set(ref status, value); } + public bool HasMore => shown < all.Count; + public bool IsEmpty => !loading && all.Count == 0; + public string FolderText => Root; + + public async Task RefreshAsync() + { + loading = true; + Status = "Looking for screenshots..."; + var found = await Task.Run(() => + { + if (!Directory.Exists(Root)) return new List(); + return Directory.EnumerateFiles(Root, "*.png", System.IO.SearchOption.AllDirectories) + .Select(f => (f, t: File.GetLastWriteTimeUtc(f))) + .OrderByDescending(x => x.t) + .Select(x => x.f) + .ToList(); + }); + loading = false; + all = found; + shown = 0; + Items.Clear(); + LoadMore(); + Raise(nameof(IsEmpty)); + } + + void LoadMore() + { + var batch = all.Skip(shown).Take(PageSize).ToList(); + shown += batch.Count; + foreach (var path in batch) + { + var item = new GalleryItemVM(this, path); + Items.Add(item); + _ = LoadThumbAsync(item); + } + Status = all.Count == 0 ? "No screenshots yet." : $"Showing {shown} of {all.Count} screenshots"; + Raise(nameof(HasMore)); + } + + static readonly SemaphoreSlim DecodeGate = new(3); + + static async Task LoadThumbAsync(GalleryItemVM item) + { + await DecodeGate.WaitAsync(); + try + { + var bmp = await Task.Run(() => + { + try + { + using var fs = File.OpenRead(item.Path); + return Bitmap.DecodeToWidth(fs, 360); + } + catch { return null; } + }); + if (bmp != null) Dispatcher.UIThread.Post(() => item.Thumb = bmp); + } + finally { DecodeGate.Release(); } + } + + public async Task CopyAsync(GalleryItemVM item) + { + if (main.CopyImage == null) return; + try + { + await main.CopyImage(item.Path); + main.ReportStatus($"Copied {item.Name} to the clipboard."); + } + catch (Exception e) { main.ReportStatus($"Could not copy the image: {e.Message}"); } + } + + public async Task DeleteAsync(GalleryItemVM item) + { + if (main.Confirm != null && !await main.Confirm($"Delete {item.Name}?\nIt is moved to the Recycle Bin.")) return; + try + { + await Task.Run(() => FileSystem.DeleteFile(item.Path, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin)); + Items.Remove(item); + all.Remove(item.Path); + shown = Math.Max(0, shown - 1); + Status = $"Showing {shown} of {all.Count} screenshots"; + Raise(nameof(HasMore)); + Raise(nameof(IsEmpty)); + } + catch (Exception e) { main.ReportStatus($"Could not delete the image: {e.Message}"); } + } + + public void Open(GalleryItemVM item) => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(item.Path) { UseShellExecute = true }); + + public void Show(GalleryItemVM item) => + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", $"/select,\"{item.Path}\"") { UseShellExecute = true }); +} diff --git a/ViewModels/ItemViewModels.cs b/ViewModels/ItemViewModels.cs new file mode 100644 index 0000000..ce58970 --- /dev/null +++ b/ViewModels/ItemViewModels.cs @@ -0,0 +1,934 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using CVRManager.Models; +using CVRManager.Services; + +namespace CVRManager.ViewModels; + +public class AssignmentVM : ObservableObject +{ + readonly InstallVM owner; + public ModAssignment Info { get; } + public ModDef Mod { get; } + + public AssignmentVM(InstallVM owner, ModAssignment info, ModDef mod) + { + this.owner = owner; + Info = info; + Mod = mod; + RemoveCommand = new RelayCommand(() => owner.RemoveAssignment(this)); + } + + public string Name => Mod.Name; + public List Versions => new[] { "latest" }.Concat(Mod.Releases.Select(r => r.Tag)).Concat(Mod.Local ? new[] { "local" } : Array.Empty()).Distinct().ToList(); + + public string Version + { + get => Info.Version; + set + { + if (value == null || Info.Version == value) return; + Info.Version = value; + Raise(); + Raise(nameof(Resolved)); + owner.MarkPending(); + } + } + + public bool Enabled + { + get => Info.Enabled; + set + { + if (Info.Enabled == value) return; + Info.Enabled = value; + Raise(); + owner.MarkPending(); + } + } + + public string Resolved + { + get + { + var tag = ModStore.ResolveTag(Mod, Info.Version); + if (tag.Length == 0) return "no versions"; + var deployed = ModStore.IsDownloaded(Mod, tag) ? "downloaded" : "not downloaded"; + return Info.Version == "latest" ? $"= {tag} ({deployed})" : deployed; + } + } + + public ICommand RemoveCommand { get; } + public void RefreshVersions() { Raise(nameof(Versions)); Raise(nameof(Resolved)); } +} + +public class InstallVM : ObservableObject +{ + public MainViewModel Main { get; } + public InstallInfo Info { get; } + public ObservableCollection Mods { get; } = new(); + public ObservableCollection Hooks { get; } = new(); + + string installedBuild = ""; + string cacheText = ""; + bool pending; + string? mlVersionChoice; + ModDef? addChoice; + + public InstallVM(MainViewModel main, InstallInfo info) + { + Main = main; + Info = info; + ImportModsCommand = new RelayCommand(() => Main.ImportInstallModsAsync(this)); + LaunchCommand = new RelayCommand(() => Main.LaunchAsync(this)); + StopCommand = new RelayCommand(() => Main.StopAsync(this)); + LaunchDesktopCommand = new RelayCommand(() => Main.LaunchAsync(this, "desktop")); + LaunchVrCommand = new RelayCommand(() => Main.LaunchAsync(this, "vr")); + UpdateCommand = new RelayCommand(() => Main.UpdateInstallAsync(this, false)); + VerifyCommand = new RelayCommand(() => Main.UpdateInstallAsync(this, true)); + OpenFolderCommand = new RelayCommand(() => Main.OpenFolder(this)); + ApplyCommand = new RelayCommand(() => Main.ApplyModsAsync(this)); + DeleteCommand = new RelayCommand(() => Main.DeleteInstallAsync(this)); + DuplicateCommand = new RelayCommand(() => Main.DuplicateInstallAsync(this)); + LinkCacheCommand = new RelayCommand(() => Main.LinkCacheAsync(this)); + AddModCommand = new RelayCommand(AddMod); + EnterCodeCommand = new RelayCommand(() => Main.BeginCodeEntry(this)); + ToggleMlCommand = new RelayCommand(() => Main.ToggleMelonLoaderAsync(this)); + SetMlVersionCommand = new RelayCommand(() => Main.SetMelonLoaderVersionAsync(this)); + Rebuild(); + } + + public ICommand ImportModsCommand { get; } + public ICommand LaunchCommand { get; } + public ICommand StopCommand { get; } + public ICommand LaunchDesktopCommand { get; } + public ICommand LaunchVrCommand { get; } + public ICommand UpdateCommand { get; } + public ICommand VerifyCommand { get; } + public ICommand OpenFolderCommand { get; } + public ICommand ApplyCommand { get; } + public ICommand DeleteCommand { get; } + public ICommand DuplicateCommand { get; } + public ICommand LinkCacheCommand { get; } + public ICommand AddModCommand { get; } + public ICommand EnterCodeCommand { get; } + public ICommand ToggleMlCommand { get; } + public ICommand SetMlVersionCommand { get; } + + public string Name + { + get => Info.Name; + set { if (Info.Name != value) { Info.Name = value; Raise(); Raise(nameof(Title)); Main.Save(); Main.RefreshModUsage(); Main.ScheduleFolderRename(this); } } + } + + public string Branch => Info.Branch; + + public IReadOnlyList LaunchModes => LaunchModeOption.All; + + public LaunchModeOption LaunchModeChoice + { + get => LaunchModeOption.Find(Info.LaunchMode); + set + { + if (value == null || Info.LaunchMode == value.Key) return; + Info.LaunchMode = value.Key; + Raise(); + Raise(nameof(LaunchTip)); + Main.Save(); + } + } + + public string LaunchTip => $"Launch ({LaunchModeChoice.Label})"; + + public AccountChoice? AccountChoice + { + get => Main.AccountChoices.FirstOrDefault(c => c.Id == Info.AccountId) ?? Main.AccountChoices.FirstOrDefault(); + set + { + if (value == null || value.Id == Info.AccountId) return; + Info.AccountId = value.Id; + Raise(); + Main.Save(); + } + } + + public void RaiseAccount() => Raise(nameof(AccountChoice)); + + public string LaunchArgs + { + get => Info.LaunchArgs; + set { if (Info.LaunchArgs != value) { Info.LaunchArgs = value; Raise(); Main.Save(); } } + } + + LaunchArgOption? argChoice; + public LaunchArgOption? ArgChoice + { + get => argChoice; + set + { + if (value == null) return; + var text = value.Insert; + var current = Info.LaunchArgs.Trim(); + if (!(" " + current + " ").Contains(" " + text.TrimEnd() + " ", StringComparison.Ordinal)) + LaunchArgs = current.Length == 0 ? text : current + " " + text; + argChoice = value; + Raise(); + Avalonia.Threading.Dispatcher.UIThread.Post(() => { argChoice = null; Raise(); }); + } + } + + public BranchChoice? BranchHint + { + get => Main.BranchChoices.FirstOrDefault(c => c.Name == Info.Branch); + set + { + if (value == null || value.Name == Info.Branch) return; + Info.Branch = value.Name; + Info.BranchPassword = value.Code; + Raise(); + Raise(nameof(Branch)); + Raise(nameof(Title)); + RaiseStatus(); + Main.Save(); + Main.RefreshModUsage(); + } + } + + string subTab = "overview"; + public bool ShowOverview { get => subTab == "overview"; set { if (value) SetSub("overview"); } } + public bool ShowModsTab { get => subTab == "mods"; set { if (value) SetSub("mods"); } } + public bool ShowLogs { get => subTab == "logs"; set { if (value) SetSub("logs"); } } + + void SetSub(string t) + { + if (subTab == t) return; + subTab = t; + Raise(nameof(ShowOverview)); + Raise(nameof(ShowModsTab)); + Raise(nameof(ShowLogs)); + mlStamp = unityStamp = default; + RefreshLogs(); + } + + string logTab = "ml"; + public bool ShowMlLog { get => logTab == "ml"; set { if (value) { logTab = "ml"; Raise(); Raise(nameof(ShowUnityLog)); } } } + public bool ShowUnityLog { get => logTab == "unity"; set { if (value) { logTab = "unity"; Raise(); Raise(nameof(ShowMlLog)); } } } + + string mlLog = ""; + string unityLog = ""; + string mlRaw = ""; + long mlColourVersion = -1; + (long, long) mlStamp, unityStamp; + public string MlLogText { get => mlLog; private set => Set(ref mlLog, value); } + public string UnityLogText { get => unityLog; private set => Set(ref unityLog, value); } + + bool logReading; + + public void RefreshLogs() + { + if (!ShowLogs || logReading) return; + logReading = true; + var gameDir = Paths.GameDir(Info); + var ml = Path.Combine(gameDir, "MelonLoader", "Latest.log"); + var ms = mlStamp; + var us = unityStamp; + var raw = mlRaw; + var seen = mlColourVersion; + _ = Task.Run(() => + { + string? a = null, b = null; + var version = seen; + try + { + var dataDir = Paths.GameDataDir(gameDir); + var unity = Path.Combine(dataDir, "Player.log"); + var prev = Path.Combine(dataDir, "Player-prev.log"); + b = ReadTail(unity, ref us, "No Unity log yet."); + var fresh = ReadTail(ml, ref ms, ""); + version = MlLogColours.Refresh(prev, unity); + if (fresh != null) raw = fresh; + if (fresh != null || version != seen) + a = raw.Length == 0 ? "No MelonLoader log yet." : MlLogColours.Colour(raw, prev, unity); + } + finally + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + logReading = false; + mlStamp = ms; + unityStamp = us; + mlRaw = raw; + mlColourVersion = version; + if (a != null) MlLogText = a; + if (b != null) UnityLogText = b; + }); + } + }); + } + + static string? ReadTail(string path, ref (long, long) stamp, string empty) + { + try + { + if (!File.Exists(path)) + { + if (stamp == (-1, 0)) return null; + stamp = (-1, 0); + return empty; + } + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + var s = (fs.Length, 0L); + if (s == stamp) return null; + stamp = s; + const int max = 262144; + if (fs.Length > max) fs.Seek(-max, SeekOrigin.End); + using var r = new StreamReader(fs); + return r.ReadToEnd(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return null; } + } + + public string Title => $"{Info.Name} · {Info.Branch}"; + public string InstalledBuild { get => installedBuild; private set { if (Set(ref installedBuild, value)) { Raise(nameof(UpdateText)); Raise(nameof(HasGame)); } } } + public bool HasGame => File.Exists(Paths.GameExe(Info)); + + public string UpdateText + { + get + { + var remote = Main.Branches.FirstOrDefault(b => b.Name == Info.Branch)?.BuildId ?? ""; + var local = string.IsNullOrEmpty(InstalledBuild) ? "not installed" : $"build {InstalledBuild}"; + if (remote.Length == 0) return $"{local} · remote build unknown"; + if (InstalledBuild.Length == 0) return $"{local} · remote build {remote}"; + return InstalledBuild == remote ? $"{local} · up to date" : $"{local} · update available (build {remote})"; + } + } + + string installedBranch = ""; + public string InstalledBranch { get => installedBranch; private set => Set(ref installedBranch, value); } + + public string RemoteBuild => Main.Branches.FirstOrDefault(b => b.Name == Info.Branch)?.BuildId ?? ""; + public bool NotInstalled => InstalledBuild.Length == 0; + public bool BranchMismatch => InstalledBuild.Length > 0 && InstalledBranch.Length > 0 && !InstalledBranch.Equals(Info.Branch, StringComparison.OrdinalIgnoreCase); + public bool UpdateAvailable => BranchMismatch || (InstalledBuild.Length > 0 && RemoteBuild.Length > 0 && InstalledBuild != RemoteBuild); + public bool UpdateUnknown => InstalledBuild.Length > 0 && !BranchMismatch && RemoteBuild.Length == 0; + public bool UpToDate => InstalledBuild.Length > 0 && RemoteBuild.Length > 0 && !UpdateAvailable; + public string UpdateChipText => BranchMismatch ? $"Files are from {InstalledBranch}: update to switch to {Info.Branch}" : $"Update available: build {RemoteBuild}"; + public string BuildText => InstalledBuild.Length > 0 ? $"Build {InstalledBuild}" : "Not installed"; + public string CardStatus => NotInstalled ? "Not installed" + : BranchMismatch ? $"Out of date: {InstalledBranch} files" + : UpdateAvailable ? $"Out of date: build {InstalledBuild}" + : UpdateUnknown ? $"Build {InstalledBuild}: latest unknown" + : $"Build {InstalledBuild}"; + + void RaiseStatus() + { + Raise(nameof(UpdateText)); + Raise(nameof(RemoteBuild)); + Raise(nameof(NotInstalled)); + Raise(nameof(BranchMismatch)); + Raise(nameof(UpdateAvailable)); + Raise(nameof(UpdateUnknown)); + Raise(nameof(UpToDate)); + Raise(nameof(UpdateChipText)); + Raise(nameof(BuildText)); + Raise(nameof(CardStatus)); + } + + int instances; + public bool IsRunning => instances > 0; + public int InstanceCount => instances; + public string RunningText => instances > 1 ? $"Running ×{instances}" : instances == 1 ? "Running" : ""; + public string StopText => instances > 1 ? $"■ Stop all ({instances})" : "■ Stop"; + public string StopShortText => instances > 1 ? $"■ ×{instances}" : "■"; + public string StopTip => instances > 1 ? "Stop all running instances of this install" : "Stop the game"; + + public void InstanceStarted() { instances++; NotifyInstances(); } + public void InstanceEnded() { if (instances > 0) instances--; NotifyInstances(); } + public void ResetInstances() { instances = 0; NotifyInstances(); } + + void NotifyInstances() + { + Raise(nameof(IsRunning)); + Raise(nameof(InstanceCount)); + Raise(nameof(RunningText)); + Raise(nameof(StopText)); + Raise(nameof(StopShortText)); + Raise(nameof(StopTip)); + } + public string CacheText { get => cacheText; private set => Set(ref cacheText, value); } + public bool Pending { get => pending; private set { if (Set(ref pending, value)) Raise(nameof(PendingText)); } } + public string PendingText => pending ? "Unapplied mod changes" : ""; + + public bool MlPresent => File.Exists(Path.Combine(Paths.GameDir(Info), "version.dll")); + public bool MlOffPresent => File.Exists(Path.Combine(Paths.GameDir(Info), "version.dll.off")); + public string? MlInstalledVersion + { + get + { + foreach (var sub in new[] { "net35", "net6" }) + { + var dll = Path.Combine(Paths.GameDir(Info), "MelonLoader", sub, "MelonLoader.dll"); + if (!File.Exists(dll)) continue; + var v = System.Diagnostics.FileVersionInfo.GetVersionInfo(dll).ProductVersion?.Split('+', '-')[0]; + if (!string.IsNullOrEmpty(v)) return v; + } + return null; + } + } + + public ModAssignment? MlAssignment => Info.Mods.FirstOrDefault(m => m.ModId == ModDef.MelonLoaderId); + + public string MlText + { + get + { + var a = MlAssignment; + var installed = MlInstalledVersion is { } iv ? $" · files are v{iv}" : ""; + if (MlPresent) return (a is { Enabled: true } ? $"MelonLoader enabled · managed {a.Version}" : "MelonLoader enabled · not managed by this app") + installed; + if (MlOffPresent) return "MelonLoader disabled · version.dll renamed to version.dll.off"; + return a != null ? "MelonLoader disabled" : "No MelonLoader"; + } + } + + public string MlToggleText => MlPresent ? "Disable MelonLoader" : "Enable MelonLoader"; + public IEnumerable MlVersions => Main.MelonLoader.Releases.Select(r => r.Tag); + + public string? MlVersionChoice + { + get => mlVersionChoice + ?? (MlAssignment is { } a && a.Version != "latest" ? a.Version : null) + ?? (MlInstalledVersion is { } v ? Main.MelonLoader.Releases.Select(r => r.Tag).FirstOrDefault(t => t.TrimStart('v') == v) : null); + set { if (Set(ref mlVersionChoice, value)) Raise(); } + } + + public IEnumerable AddableMods => Main.ModCatalog + .Where(m => m.Id != ModDef.MelonLoaderId && Info.Mods.All(a => a.ModId != m.Id)); + + public ModDef? AddChoice { get => addChoice; set => Set(ref addChoice, value); } + + void AddMod() + { + if (AddChoice == null) return; + var a = new ModAssignment { ModId = AddChoice.Id }; + Info.Mods.Add(a); + Mods.Add(new AssignmentVM(this, a, AddChoice)); + AddChoice = null; + Raise(nameof(AddableMods)); + MarkPending(); + } + + public void RemoveAssignment(AssignmentVM vm) + { + Info.Mods.Remove(vm.Info); + Mods.Remove(vm); + Raise(nameof(AddableMods)); + MarkPending(true); + } + + public void MarkPending(bool force = false) + { + Pending = true; + Main.Save(); + Main.NotifyAssignments(); + } + + public void ClearPending() => Pending = false; + + public void RebuildHooks() + { + Hooks.Clear(); + foreach (var h in Main.HookCatalog) Hooks.Add(new InstallHookVM(this, h)); + } + + public void Rebuild() + { + RebuildHooks(); + Mods.Clear(); + foreach (var a in Info.Mods.Where(a => a.ModId != ModDef.MelonLoaderId).ToList()) + { + var mod = Main.ModCatalog.FirstOrDefault(m => m.Id == a.ModId); + if (mod != null) Mods.Add(new AssignmentVM(this, a, mod)); + } + Refresh(); + } + + public void Refresh() + { + InstalledBuild = AppManifest.LocalBuildId(Info); + InstalledBranch = AppManifest.LocalBranch(Info); + CacheText = CacheService.GetState(Info) switch + { + CacheState.Shared => "Cache: shared (junction to the shared cache)", + CacheState.Local => "Cache: stored inside this install, not shared", + CacheState.LinkedElsewhere => "Cache: linked to a folder other than the shared cache", + _ => "Cache: not created yet", + }; + Raise(nameof(HasGame)); + RaiseStatus(); + Raise(nameof(MlText)); + Raise(nameof(MlToggleText)); + Raise(nameof(MlVersions)); + Raise(nameof(MlVersionChoice)); + Raise(nameof(AddableMods)); + Raise(nameof(BranchHint)); + foreach (var m in Mods) m.RefreshVersions(); + } +} + +public class ReleaseRow +{ + public ReleaseRow(ModDef mod, ReleaseInfo release) + { + Tag = release.Tag; + var parts = new List { release.Tag + (release.Prerelease ? " (pre-release)" : "") }; + if (release.Published != default) parts.Add(release.Published.ToString("yyyy-MM-dd")); + if (release.GameVersion.Length > 0) parts.Add("game " + release.GameVersion); + if (release.LoaderVersion.Length > 0) parts.Add("MelonLoader " + release.LoaderVersion); + if (release.Snapshot) parts.Add("the file from your install"); + parts.Add(ModStore.IsDownloaded(mod, release.Tag) ? "downloaded" : "not downloaded"); + Text = string.Join(" · ", parts); + } + + public string Tag { get; } + public string Text { get; } +} + +public class SourceVM : ObservableObject +{ + readonly MainViewModel main; + public MainViewModel Main => main; + public SourceDef Info { get; } + + public SourceVM(MainViewModel main, SourceDef info) + { + this.main = main; + Info = info; + RemoveCommand = new RelayCommand(() => main.RemoveSource(this)); + } + + public ObservableCollection Mods { get; } = new(); + string filter = ""; + public string Filter { get => filter; set { if (Set(ref filter, value)) Raise(nameof(VisibleMods)); } } + public IEnumerable VisibleMods => Mods.Where(m => filter.Trim().Length == 0 + || m.Name.Contains(filter.Trim(), StringComparison.OrdinalIgnoreCase) + || m.Info.Author.Contains(filter.Trim(), StringComparison.OrdinalIgnoreCase) + || m.Info.Description.Contains(filter.Trim(), StringComparison.OrdinalIgnoreCase)); + + public void SetMods(IEnumerable mods) + { + Mods.Clear(); + foreach (var m in mods) Mods.Add(m); + Raise(nameof(VisibleMods)); + } + + public string Name => Info.Name; + public string Detail => Info.Kind == "cvrmg" ? "cvrmg.com" : $"github.com/{Info.Repo}"; + public bool CanRemove => !Info.Builtin || Info.Kind != "cvrmg"; + public ICommand RemoveCommand { get; } + int count = -1; + public int Count { get => count; set { if (Set(ref count, value)) Raise(nameof(CountText)); } } + public string CountText => count < 0 ? "loading..." : $"{count} mods"; + public string Kind => Info.Kind == "cvrmg" ? "CVRMG" : "GitHub releases"; + public string Link => Info.Kind == "cvrmg" ? "https://cvrmg.com" : $"https://github.com/{Info.Repo}"; +} + +public class GroupHeader : ObservableObject +{ + readonly Action toggle; + bool expanded = true; + + public GroupHeader(string title, int count, bool expanded, Action toggle) + { + Title = title; + Count = count; + this.expanded = expanded; + this.toggle = toggle; + ToggleCommand = new RelayCommand(() => this.toggle(this)); + } + + public string Title { get; } + public int Count { get; } + public bool Expanded => expanded; + public string Text => $"{(expanded ? "▾" : "▸")} {Title} ({Count})"; + public ICommand ToggleCommand { get; } +} + +public class ModVM : ObservableObject +{ + public MainViewModel Main { get; } + public ModDef Info { get; } + public ObservableCollection Releases { get; } = new(); + ReleaseRow? selected; + + public static string[] Types { get; } = { "Mods", "Plugins", "UserLibs", "Root" }; + + public ModVM(MainViewModel main, ModDef info) + { + Main = main; + Info = info; + RefreshCommand = new RelayCommand(() => Main.RefreshModAsync(this)); + DownloadCommand = new RelayCommand(() => Main.DownloadModAsync(this)); + DeleteDownloadCommand = new RelayCommand(() => Main.DeleteModDownloadAsync(this)); + RemoveCommand = new RelayCommand(() => Main.RemoveModAsync(this)); + AddLocalCommand = new RelayCommand(() => Main.AddLocalFilesAsync(this)); + ManageCommand = new RelayCommand(() => Main.ManagedMod = this); + Rebuild(); + } + + static readonly Dictionary FileVersions = new(); + + string? FileVersionOf(string tag) + { + var dir = Paths.ModFilesDir(Info, tag); + if (!ModStore.IsDownloaded(Info, tag) || !Directory.Exists(dir)) return null; + if (FileVersions.TryGetValue(dir, out var cached)) return cached; + var dll = Directory.EnumerateFiles(dir, "*.dll", SearchOption.AllDirectories) + .OrderBy(f => ModImportService.Norm(Path.GetFileName(f)) == ModImportService.Norm(Info.FileName) ? 0 : 1).FirstOrDefault(); + return FileVersions[dir] = dll == null ? "" : AssemblyReader.Read(dll).Version; + } + + string InstalledVersion + { + get + { + if (Assignment is not { Enabled: true } a) return ""; + var tag = ModStore.ResolveTag(Info, a.Version); + var rel = Info.Releases.FirstOrDefault(r => r.Tag == tag); + if (rel?.Snapshot == true) + { + var i = tag.IndexOf(" (", StringComparison.Ordinal); + return tag.StartsWith("installed ", StringComparison.Ordinal) ? tag[10..(i > 10 ? i : tag.Length)] : tag; + } + var fromFile = FileVersionOf(tag); + return string.IsNullOrEmpty(fromFile) ? "" : fromFile; + } + } + + public string InstalledText + { + get + { + if (Assignment is not { Enabled: true } a) return "—"; + var v = InstalledVersion; + return v.Length > 0 ? v : ModStore.ResolveTag(Info, a.Version) is { Length: > 0 } t ? t : "—"; + } + } + + ReleaseInfo? Newest => Info.Releases.FirstOrDefault(r => !r.Snapshot && !r.Prerelease) ?? Info.Releases.FirstOrDefault(r => !r.Snapshot); + public string LatestText => Info.LatestVersion.Length > 0 ? Info.LatestVersion : "—"; + + static int CompareVersions(string a, string b) + { + a = a.Trim().TrimStart('v', 'V'); + b = b.Trim().TrimStart('v', 'V'); + if (Version.TryParse(a, out var x) && Version.TryParse(b, out var y)) + return (x.Major, x.Minor, Math.Max(x.Build, 0), Math.Max(x.Revision, 0)).CompareTo((y.Major, y.Minor, Math.Max(y.Build, 0), Math.Max(y.Revision, 0))); + + var ca = System.Text.RegularExpressions.Regex.Matches(a, @"\d+|\D+"); + var cb = System.Text.RegularExpressions.Regex.Matches(b, @"\d+|\D+"); + for (var i = 0; i < Math.Min(ca.Count, cb.Count); i++) + { + var pa = ca[i].Value; + var pb = cb[i].Value; + var c = char.IsDigit(pa[0]) && char.IsDigit(pb[0]) + ? System.Numerics.BigInteger.Parse(pa).CompareTo(System.Numerics.BigInteger.Parse(pb)) + : string.Compare(pa, pb, StringComparison.OrdinalIgnoreCase); + if (c != 0) return c; + } + return ca.Count.CompareTo(cb.Count); + } + + public bool UpdateAvailable + { + get + { + if (Assignment is not { Enabled: true } a) return false; + var installed = InstalledVersion; + if (installed.Length > 0 && Info.LatestVersion.Length > 0) return CompareVersions(Info.LatestVersion, installed) > 0; + if (Newest == null) return false; + var tag = ModStore.ResolveTag(Info, a.Version); + var rel = Info.Releases.FirstOrDefault(r => r.Tag == tag); + return rel is { Snapshot: false } && tag != Newest.Tag; + } + } + + public string StatusText => Assignment is not { Enabled: true } ? "" : UpdateAvailable ? "Update available" : "Installed"; + public string ShortDescription => Info.Description.Split('\n')[0].Trim(); + public string AuthorLine => string.Join(" · ", new[] { Info.Author, Info.Source == "cvrmg" ? "CVRMG" : Info.Source == "github" ? Info.Repo : "local" }.Where(x => x.Length > 0)); + public string CategoryText => Info.Category.Length > 0 ? Info.Category : Info.Source == "local" ? "Local files" : "Uncategorized"; + + InstallVM? Current => Main.SelectedInstall; + ModAssignment? Assignment => Current?.Info.Mods.FirstOrDefault(a => a.ModId == Info.Id); + public bool HasInstall => Current != null; + public bool InstallAssigned => Assignment != null; + + public bool InCatalog => Main.IsInCatalog(Info); + + public bool InstallUsed + { + get => Assignment is { Enabled: true }; + set + { + var cur = Current; + if (cur == null) return; + Main.EnsureInCatalog(this); + var a = Assignment; + if (value) + { + if (a == null) cur.Info.Mods.Add(a = new ModAssignment { ModId = Info.Id }); + a.Enabled = true; + } + else if (a != null) a.Enabled = false; + cur.Rebuild(); + cur.MarkPending(); + } + } + + public IEnumerable VersionChoices => new[] { "latest" }.Concat(Info.Releases.Select(r => r.Tag)).Distinct().ToList(); + + public string InstallVersion + { + get => Assignment?.Version ?? "latest"; + set + { + if (value == null || Current == null) return; + Main.EnsureInCatalog(this); + var a = Assignment; + if (a == null) Current.Info.Mods.Add(a = new ModAssignment { ModId = Info.Id }); + if (a.Version == value && a.Enabled) return; + a.Version = value; + a.Enabled = true; + Current!.Rebuild(); + Current.MarkPending(); + } + } + + public void RefreshUsedBy() => Raise(nameof(UsedBy)); + + public void NotifyInstall() + { + Raise(nameof(HasInstall)); + Raise(nameof(InstallAssigned)); + Raise(nameof(InstallUsed)); + Raise(nameof(VersionChoices)); + Raise(nameof(InstallVersion)); + Raise(nameof(ToggleInstallText)); + Raise(nameof(InstalledText)); + Raise(nameof(LatestText)); + Raise(nameof(UpdateAvailable)); + Raise(nameof(StatusText)); + Raise(nameof(ShortDescription)); + } + + public ICommand ToggleInstallCommand => new RelayCommand(() => InstallUsed = !InstallUsed); + public string ToggleInstallText => InstallUsed ? "Remove from install" : "Add to install"; + + public ICommand RefreshCommand { get; } + public ICommand DownloadCommand { get; } + public ICommand DeleteDownloadCommand { get; } + public ICommand RemoveCommand { get; } + public ICommand AddLocalCommand { get; } + public ICommand ManageCommand { get; } + + public string Name + { + get => Info.Name; + set { if (Info.Name != value) { Info.Name = value; Raise(); Main.Save(); } } + } + + public string Type + { + get => Info.Type; + set { if (value != null && Info.Type != value) { Info.Type = value; Raise(); Main.Save(); } } + } + + public string AssetFilter + { + get => Info.AssetFilter; + set { if (Info.AssetFilter != value) { Info.AssetFilter = value; Raise(); Main.Save(); } } + } + + public bool IsLocal => Info.Source == "local"; + public bool IsBuiltin => Info.Id == ModDef.MelonLoaderId; + public bool CanRemove => !IsBuiltin; + public string Source => Info.Source switch + { + "cvrmg" => $"CVRMG #{Info.CvrmgId}", + "local" => "Local files", + _ => $"github.com/{Info.Repo}", + }; + + public IEnumerable SourceOptions => new[] { Info.Repo.Length > 0 && !Info.Local ? "GitHub" : "", Info.CvrmgId > 0 ? "CVRMG" : "" }.Where(x => x.Length > 0); + public bool HasSourceChoice => SourceOptions.Count() > 1; + + public string? SelectedSource + { + get => Info.Source switch { "cvrmg" => "CVRMG", "github" => "GitHub", _ => null }; + set + { + if (value == null) return; + var s = value == "CVRMG" ? "cvrmg" : "github"; + if (s != Info.Source) _ = Main.SwitchSourceAsync(this, s); + } + } + + public string Details + { + get + { + var parts = new List(); + if (Info.Source == "cvrmg" || Info.CvrmgId > 0) parts.Add("on CVRMG"); + if (Info.Author.Length > 0) parts.Add("by " + Info.Author); + if (Info.Category.Length > 0) parts.Add(Info.Category); + if (Info.Status.Length > 0 && Info.Status != "approved") parts.Add($"CVRMG status: {Info.Status}"); + if (Info.SourceLink.Length > 0) parts.Add(Info.SourceLink); + return string.Join(" · ", parts); + } + } + + public string Description => Info.Description.Length > 600 ? Info.Description[..600] + "..." : Info.Description; + public bool HasDescription => Info.Description.Length > 0; + public ReleaseRow? SelectedRelease { get => selected; set => Set(ref selected, value); } + + public string UsedBy + { + get + { + var lines = Main.Installs + .Select(i => (i, a: i.Info.Mods.FirstOrDefault(m => m.ModId == Info.Id))) + .Where(x => x.a != null) + .Select(x => $"{x.i.Info.Name} ({x.i.Info.Branch}): {(x.a!.Enabled ? "" : "disabled, ")}{x.a.Version}") + .ToList(); + return lines.Count == 0 ? "Not used by any install" : string.Join("\n", lines); + } + } + + public void Rebuild() + { + var keep = SelectedRelease?.Tag; + Releases.Clear(); + foreach (var r in Info.Releases) Releases.Add(new ReleaseRow(Info, r)); + if (Info.Local && Info.Releases.Count == 0) Releases.Add(new ReleaseRow(Info, new ReleaseInfo { Tag = "local" })); + SelectedRelease = Releases.FirstOrDefault(r => r.Tag == keep) ?? Releases.FirstOrDefault(); + Raise(nameof(UsedBy)); + Raise(nameof(Name)); + Raise(nameof(Source)); + Raise(nameof(SelectedSource)); + Raise(nameof(Details)); + Raise(nameof(Description)); + NotifyInstall(); + } +} + + +public class InstallHookVM : ObservableObject +{ + readonly InstallVM owner; + public HookDef Hook { get; } + + public InstallHookVM(InstallVM owner, HookDef hook) + { + this.owner = owner; + Hook = hook; + RunCommand = new RelayCommand(() => owner.Main.RunHookAsync(owner, hook)); + } + + public string Name => Hook.Name; + + public string When + { + get + { + var parts = new List(); + if (Hook.BeforeUpdate) parts.Add("before update"); + if (Hook.AfterUpdate) parts.Add("after install / update"); + if (Hook.BeforeLaunch) parts.Add("before launch"); + return parts.Count == 0 ? "manual only" : string.Join(", ", parts); + } + } + + public bool Enabled + { + get => owner.Info.HookIds.Contains(Hook.Id); + set + { + if (Enabled == value) return; + if (value) owner.Info.HookIds.Add(Hook.Id); + else owner.Info.HookIds.Remove(Hook.Id); + Raise(); + owner.Main.Save(); + } + } + + public ICommand RunCommand { get; } +} + +public class AccountVM : ObservableObject +{ + public MainViewModel Main { get; } + public GameAccount Info { get; } + + public AccountVM(MainViewModel main, GameAccount info) + { + Main = main; + Info = info; + RemoveCommand = new RelayCommand(() => Main.RemoveAccountAsync(this)); + } + + public ICommand RemoveCommand { get; } + public string Username => Info.Username; + + public string Name + { + get => Info.Name; + set + { + var name = value.Trim(); + if (name.Length == 0 || Info.Name == name) return; + Info.Name = name; + Raise(); + Main.AccountsChanged(); + } + } +} + +public class HookVM : ObservableObject +{ + public MainViewModel Main { get; } + public HookDef Info { get; } + + public HookVM(MainViewModel main, HookDef info) + { + Main = main; + Info = info; + RemoveCommand = new RelayCommand(() => Main.RemoveHookAsync(this)); + } + + public ICommand RemoveCommand { get; } + + public bool CanRemove => true; + + public string Name + { + get => Info.Name; + set { if (Info.Name != value) { Info.Name = value; Raise(); Main.HooksChanged(); } } + } + + public string Command + { + get => Info.Command; + set { if (Info.Command != value) { Info.Command = value; Raise(); Main.Save(); } } + } + + public string Arguments + { + get => Info.Arguments; + set { if (Info.Arguments != value) { Info.Arguments = value; Raise(); Main.Save(); } } + } + + public bool BeforeUpdate { get => Info.BeforeUpdate; set { if (Info.BeforeUpdate != value) { Info.BeforeUpdate = value; Raise(); Main.HooksChanged(); } } } + public bool AfterUpdate { get => Info.AfterUpdate; set { if (Info.AfterUpdate != value) { Info.AfterUpdate = value; Raise(); Main.HooksChanged(); } } } + public bool BeforeLaunch { get => Info.BeforeLaunch; set { if (Info.BeforeLaunch != value) { Info.BeforeLaunch = value; Raise(); Main.HooksChanged(); } } } + public bool DefaultOn { get => Info.DefaultOn; set { if (Info.DefaultOn != value) { Info.DefaultOn = value; Raise(); Main.Save(); } } } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..4c63708 --- /dev/null +++ b/ViewModels/MainViewModel.cs @@ -0,0 +1,1638 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Text; +using System.Windows.Input; +using Avalonia.Threading; +using CVRManager.Models; +using CVRManager.Services; + +namespace CVRManager.ViewModels; + +public class MainViewModel : ObservableObject +{ + readonly AppConfig cfg; + readonly GitHubService github; + readonly ModStore store; + readonly CvrmgService cvrmg = new(); + readonly ModImportService importer; + readonly StringBuilder log = new(); + CancellationTokenSource? cts; + + bool busy; + string status = "Ready"; + double progress; + bool indeterminate; + InstallVM? selectedInstall; + ModVM? selectedMod; + bool showNew; + string newName = ""; + BranchChoice? newChoice; + string? newImportPath; + bool newMoveCache = true; + bool newDownload = true; + string addModRepo = ""; + string cacheSize = ""; + + public Func>? PickFolder { get; set; } + public Func>>? PickFiles { get; set; } + public Func>? Confirm { get; set; } + public Func? CopyImage { get; set; } + public GalleryViewModel Gallery { get; } + public void ReportStatus(string text) => Status = text; + + public MainViewModel() + { + cfg = ConfigStore.Load(); + ThemeService.Apply(cfg.Theme); + github = new GitHubService(() => cfg.GitHubToken); + store = new ModStore(github); + importer = new ModImportService(github, cvrmg); + Gallery = new GalleryViewModel(this); + + foreach (var m in cfg.Mods) Mods.Add(new ModVM(this, m)); + foreach (var h in cfg.Hooks) Hooks.Add(new HookVM(this, h)); + foreach (var a in cfg.Accounts) Accounts.Add(new AccountVM(this, a)); + RebuildAccountChoices(); + foreach (var src in cfg.Sources) Sources.Add(new SourceVM(this, src)); + AvailableMods = Mods.ToList(); + selectedSource = Sources.FirstOrDefault(); + Mods.CollectionChanged += (_, _) => RegroupMods(); + RegroupMods(); + foreach (var i in cfg.Installs) Installs.Add(new InstallVM(this, i)); + RebuildChoices(); + newChoice = BranchChoices.FirstOrDefault(c => c.Name == "public"); + Installs.CollectionChanged += (_, _) => { Raise(nameof(NoInstalls)); Raise(nameof(ShowDetail)); }; + selectedInstall = Installs.FirstOrDefault(); + selectedMod = Mods.FirstOrDefault(m => m.Info.Id != ModDef.MelonLoaderId); + + CreateInstallCommand = new RelayCommand(CreateInstallAsync); + ShowNewCommand = new RelayCommand(() => { ShowNew = true; NewImport = false; DetectExisting(); }); + CancelNewCommand = new RelayCommand(() => ShowNew = false); + RefreshBranchesCommand = new RelayCommand(RefreshBranchesAsync); + CancelCommand = new RelayCommand(() => cts?.Cancel()); + AddModCommand = new RelayCommand(AddModAsync); + AddLocalNewCommand = new RelayCommand(AddLocalFilesNewAsync); + RefreshAllModsCommand = new RelayCommand(RefreshAllModsAsync); + DedupeCommand = new RelayCommand(DedupeAllAsync); + ApplyLibraryRootCommand = new RelayCommand(ApplyLibraryRoot); + BrowseLibraryRootCommand = new RelayCommand(BrowseLibraryRootAsync); + BrowseImportCommand = new RelayCommand(BrowseImportAsync); + OpenLibraryCommand = new RelayCommand(() => Explorer(Paths.Root)); + OpenCacheCommand = new RelayCommand(() => Explorer(Paths.SharedCache)); + OpenModStoreCommand = new RelayCommand(() => Explorer(Paths.ModStore)); + + var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + timer.Tick += (_, _) => { if (IsInstallsPage) SelectedInstall?.RefreshLogs(); }; + timer.Start(); + + Log($"Library: {Paths.Root}"); + _ = StartupAsync(); + } + + public ObservableCollection Installs { get; } = new(); + public ObservableCollection Mods { get; } = new(); + public ObservableCollection Hooks { get; } = new(); + public ObservableCollection Accounts { get; } = new(); + public ObservableCollection AccountChoices { get; } = new(); + public bool HasAccounts => Accounts.Count > 0; + string newAccountUser = ""; + string newAccountKey = ""; + + public string NewAccountUser { get => newAccountUser; set => Set(ref newAccountUser, value); } + public string NewAccountKey { get => newAccountKey; set => Set(ref newAccountKey, value); } + + public void RebuildAccountChoices() + { + AccountChoices.Clear(); + AccountChoices.Add(new AccountChoice("", "Game login")); + foreach (var a in cfg.Accounts) AccountChoices.Add(new AccountChoice(a.Id, a.Name)); + Raise(nameof(HasAccounts)); + foreach (var i in Installs) i.RaiseAccount(); + } + + public void AccountsChanged() + { + Save(); + RebuildAccountChoices(); + } + + GameAccount AddAccount(string name, string username, string userId, string key) + { + var a = new GameAccount { Name = name, Username = username, UserId = userId, AccessKey = TokenVault.Protect(key) }; + cfg.Accounts.Add(a); + Accounts.Add(new AccountVM(this, a)); + return a; + } + + public ICommand AddAccountCommand => new RelayCommand(() => + { + var user = NewAccountUser.Trim(); + var key = NewAccountKey.Trim(); + if (user.Length == 0 || key.Length == 0) { Status = "Enter a username and an access key."; return; } + AddAccount(user, user, "", key); + NewAccountUser = NewAccountKey = ""; + AccountsChanged(); + }); + + public ICommand ImportAccountsCommand => new RelayCommand(async () => + { + var dirs = cfg.Installs.Select(Paths.GameDir).ToList(); + var found = await Task.Run(() => AccountService.Scan(dirs).ToList()); + var known = cfg.Accounts.Select(a => (a.Username, TokenVault.Unprotect(a.AccessKey))).ToHashSet(); + var added = 0; + foreach (var f in found) + { + if (!known.Add((f.Username, f.AccessKey))) continue; + AddAccount(f.Name, f.Username, f.UserId, f.AccessKey); + added++; + } + Status = added == 0 ? "No new accounts found." : $"Imported {added} account{(added == 1 ? "" : "s")}."; + if (added > 0) AccountsChanged(); + }); + + public async Task RemoveAccountAsync(AccountVM vm) + { + if (Confirm != null && !await Confirm($"Remove the account '{vm.Info.Name}'?")) return; + cfg.Accounts.Remove(vm.Info); + Accounts.Remove(vm); + foreach (var i in cfg.Installs.Where(i => i.AccountId == vm.Info.Id)) i.AccountId = ""; + AccountsChanged(); + } + public IEnumerable HookCatalog => cfg.Hooks; + string? hookWarning; + + public void HooksChanged() + { + Save(); + foreach (var i in Installs) i.RebuildHooks(); + } + + public ICommand AddHookCommand => new RelayCommand(() => + { + var def = new HookDef { Name = "New hook" }; + cfg.Hooks.Add(def); + Hooks.Add(new HookVM(this, def)); + foreach (var i in Installs) if (def.DefaultOn) i.Info.HookIds.Add(def.Id); + HooksChanged(); + }); + + public async Task RemoveHookAsync(HookVM vm) + { + if (!vm.CanRemove) return; + if (Confirm != null && !await Confirm($"Remove the hook '{vm.Info.Name}'?")) return; + cfg.Hooks.Remove(vm.Info); + Hooks.Remove(vm); + foreach (var i in Installs) i.Info.HookIds.Remove(vm.Info.Id); + HooksChanged(); + } + + public Task RunHookAsync(InstallVM vm, HookDef hook) => Run($"Running {hook.Name} on {vm.Info.Name}", async ct => + { + if (!await HookService.RunAsync(hook, vm.Info, vm.InstalledBuild, Log, ct)) + throw new InvalidOperationException($"{hook.Name} reported problems; see the activity log in Settings."); + }); + + async Task RunHooksAsync(InstallVM vm, string evt, CancellationToken ct) + { + var failed = await HookService.RunEventAsync(cfg.Hooks, vm.Info, evt, AppManifest.LocalBuildId(vm.Info), Log, ct); + if (failed.Count > 0) hookWarning = $"hook problems: {string.Join(", ", failed)} (see the activity log in Settings)"; + } + public ObservableCollection Branches { get; } = new(); + public ObservableCollection BranchChoices { get; } = new(); + public ObservableCollection DetectedInstalls { get; } = new(); + public bool HasDetected => DetectedInstalls.Count > 0; + + public IEnumerable ModCatalog => cfg.Mods; + public ModDef MelonLoader => cfg.Mods.First(m => m.Id == ModDef.MelonLoaderId); + + public ICommand CreateInstallCommand { get; } + public ICommand ShowNewCommand { get; } + public ICommand CancelNewCommand { get; } + public ICommand RefreshBranchesCommand { get; } + public ICommand CancelCommand { get; } + public ICommand AddModCommand { get; } + public ICommand AddLocalNewCommand { get; } + public ICommand RefreshAllModsCommand { get; } + public ICommand DedupeCommand { get; } + public ICommand ApplyLibraryRootCommand { get; } + public ICommand BrowseLibraryRootCommand { get; } + public ICommand BrowseImportCommand { get; } + public ICommand OpenLibraryCommand { get; } + public ICommand OpenCacheCommand { get; } + public ICommand OpenModStoreCommand { get; } + + string page = "installs"; + public bool IsInstallsPage { get => page == "installs"; set { if (value) SetPage("installs"); } } + public bool IsModsPage { get => page == "mods"; set { if (value) SetPage("mods"); } } + public bool IsSettingsPage { get => page == "settings"; set { if (value) SetPage("settings"); } } + public bool IsGalleryPage + { + get => page == "gallery"; + set + { + if (!value) return; + SetPage("gallery"); + if (Gallery.Items.Count == 0) _ = Gallery.RefreshAsync(); + } + } + + void SetPage(string p) + { + if (page == p) return; + page = p; + Raise(nameof(IsInstallsPage)); + Raise(nameof(IsModsPage)); + Raise(nameof(IsSettingsPage)); + Raise(nameof(IsGalleryPage)); + } + + public bool NoInstalls => Installs.Count == 0; + + public bool IsBusy { get => busy; private set { if (Set(ref busy, value)) Raise(nameof(IsIdle)); } } + public bool IsIdle => !busy; + public string Status + { + get => status; + private set + { + status = value; + Raise(); + ShowToast(value); + } + } + + bool toastVisible; + bool toastError; + int toastToken; + public bool ToastVisible { get => toastVisible; private set => Set(ref toastVisible, value); } + public bool ToastError { get => toastError; private set => Set(ref toastError, value); } + + void ShowToast(string text, bool error = false) + { + if (text == "Ready") return; + ToastError = error; + ToastVisible = true; + var token = ++toastToken; + if (error) return; + _ = Task.Run(async () => + { + await Task.Delay(5000); + Dispatcher.UIThread.Post(() => { if (token == toastToken && !IsBusy) ToastVisible = false; }); + }); + } + + public ICommand DismissToastCommand => new RelayCommand(() => ToastVisible = false); + + bool showCode, codeBusy; + string codeText = "", codeError = ""; + InstallVM? codeTarget; + public bool ShowCodeEntry { get => showCode; private set => Set(ref showCode, value); } + public string CodeText { get => codeText; set => Set(ref codeText, value); } + public string CodeError { get => codeError; private set { if (Set(ref codeError, value)) Raise(nameof(HasCodeError)); } } + public bool HasCodeError => codeError.Length > 0; + public bool CodeBusy { get => codeBusy; private set { if (Set(ref codeBusy, value)) Raise(nameof(CodeIdle)); } } + public bool CodeIdle => !codeBusy; + public string CodeIntro => codeTarget != null + ? $"Unlocked branches are added and {codeTarget.Info.Name} switches to the first." + : "Unlocked branches are added and the first is selected."; + + public void BeginCodeEntry(InstallVM? install = null) + { + codeTarget = install; + CodeText = ""; + CodeError = ""; + Raise(nameof(CodeIntro)); + ShowCodeEntry = true; + } + + public ICommand CancelCodeCommand => new RelayCommand(() => { if (!CodeBusy) ShowCodeEntry = false; }); + public ICommand EnterCodeNewCommand => new RelayCommand(() => BeginCodeEntry()); + public ICommand SubmitCodeCommand => new RelayCommand(async () => + { + var code = CodeText.Trim(); + if (CodeBusy) return; + if (code.Length == 0) { CodeError = "Type the access code first."; return; } + if (!SteamSignedIn) { CodeError = "Sign in to Steam in Settings first; Steam only checks codes for a signed-in account."; return; } + CodeError = ""; + CodeBusy = true; + try + { + var names = await CheckCodeAsync(code); + ShowCodeEntry = false; + var pick = BranchChoices.FirstOrDefault(c => c.Name == names[0]); + if (pick == null) return; + if (codeTarget != null) codeTarget.BranchHint = pick; + else NewChoice = pick; + Status = names.Count == 1 ? $"Unlocked branch {names[0]}." : $"Unlocked {names.Count} branches; selected {names[0]}."; + } + catch (Exception e) + { + CodeError = e.Message; + Log($"Access code check failed: {e.Message}"); + } + finally { CodeBusy = false; } + }); + + bool showSignIn, signInNeedsCode; + string signInPassword = "", signInCode = "", signInPrompt = ""; + TaskCompletionSource? signInReply; + public bool ShowSignIn { get => showSignIn; private set => Set(ref showSignIn, value); } + bool signInWaiting; + public bool SignInNeedsCode { get => signInNeedsCode; private set { if (Set(ref signInNeedsCode, value)) Raise(nameof(SignInNeedsPassword)); } } + public bool SignInWaiting { get => signInWaiting; private set { if (Set(ref signInWaiting, value)) { Raise(nameof(SignInNeedsPassword)); Raise(nameof(SignInCanContinue)); } } } + public bool SignInNeedsPassword => !signInNeedsCode && !signInWaiting; + public bool SignInCanContinue => !signInWaiting; + public string SignInUser { get => cfg.SteamUser; set { if (cfg.SteamUser != value) { cfg.SteamUser = value.Trim(); Raise(); Save(); } } } + public string SignInPassword { get => signInPassword; set => Set(ref signInPassword, value); } + public string SignInCode { get => signInCode; set => Set(ref signInCode, value); } + public string SignInPrompt { get => signInPrompt; private set => Set(ref signInPrompt, value); } + public bool SteamSignedIn => !string.IsNullOrEmpty(cfg.SteamToken); + public string SteamAccountText => SteamSignedIn ? $"Signed in as {cfg.SteamUser}." : "Not signed in."; + + public ICommand SteamSignInCommand => new RelayCommand(async () => + { + SignInPassword = ""; + SignInCode = ""; + SignInNeedsCode = false; + SignInWaiting = false; + SignInPrompt = "Steam sign-in"; + ShowSignIn = true; + var pass = await WaitSignInAsync(); + if (pass == null) { ShowSignIn = false; return; } + SignInPrompt = "Signing in..."; + await Run("Steam sign-in", async ct => + { + async Task Ask(string prompt) + { + SignInCode = ""; + SignInPrompt = prompt; + SignInNeedsCode = true; + ShowSignIn = true; + return await WaitSignInAsync() ?? throw new OperationCanceledException(); + } + try + { + var token = await SteamLink.SignInAsync(cfg.SteamUser, pass, + () => Dispatcher.UIThread.Post(() => + { + SignInPrompt = "Approve on your Steam mobile app."; + SignInWaiting = true; + ShowSignIn = true; + }), + wrong => Ask(wrong ? "That Steam Guard code was wrong. Enter the code from your Steam mobile app" : "Enter the Steam Guard code from your Steam mobile app"), + (mail, wrong) => Ask($"{(wrong ? "That code was wrong. " : "")}Enter the Steam Guard code emailed to {mail}"), + ct); + cfg.SteamToken = TokenVault.Protect(token); + Save(); + Log($"Signed in to Steam as {cfg.SteamUser}."); + } + finally + { + ShowSignIn = false; + SignInNeedsCode = false; + SignInWaiting = false; + Raise(nameof(SteamSignedIn)); + Raise(nameof(SteamAccountText)); + } + }); + SignInPassword = ""; + }); + + Task WaitSignInAsync() + { + signInReply = new(TaskCreationOptions.RunContinuationsAsynchronously); + return signInReply.Task; + } + + public ICommand SubmitSignInCommand => new RelayCommand(() => + { + if (!SignInNeedsCode && !SignInWaiting && (string.IsNullOrWhiteSpace(cfg.SteamUser) || SignInPassword.Length == 0)) + { + SignInPrompt = "Enter both your Steam username and password."; + return; + } + var reply = SignInNeedsCode ? SignInCode.Trim() : SignInPassword; + SignInPassword = ""; + signInReply?.TrySetResult(reply); + }); + + public ICommand CancelSignInCommand => new RelayCommand(() => signInReply?.TrySetResult(null)); + + public ICommand SteamSignOutCommand => new RelayCommand(() => + { + cfg.SteamToken = ""; + Save(); + Raise(nameof(SteamSignedIn)); + Raise(nameof(SteamAccountText)); + }); + + bool newImport; + bool NewImportDone; + public bool NewImport { get => newImport; set => Set(ref newImport, value); } + public double Progress { get => progress; private set => Set(ref progress, value); } + public bool Indeterminate { get => indeterminate; private set => Set(ref indeterminate, value); } + public string LogText { get { lock (logLock) return log.ToString(); } } + readonly object logLock = new(); + bool logRaisePending; + + public InstallVM? SelectedInstall + { + get => selectedInstall; + set { if (Set(ref selectedInstall, value)) { Raise(nameof(HasSelectedInstall)); ShowNew = false; NotifyAssignments(); } } + } + public bool HasSelectedInstall => selectedInstall != null; + + public void RefreshModUsage() + { + foreach (var m in AvailableMods) m.RefreshUsedBy(); + } + + ModVM? managedMod; + public ModVM? ManagedMod { get => managedMod; set { if (Set(ref managedMod, value)) Raise(nameof(ShowManage)); } } + public bool ShowManage => managedMod != null; + public ICommand CloseManageCommand => new RelayCommand(() => ManagedMod = null); + + public void NotifyAssignments() + { + foreach (var m in AvailableMods.Concat(Mods).Distinct().ToList()) m.NotifyInstall(); + if (modFilter != "All mods") RegroupMods(); + } + + public ObservableCollection ModRows { get; } = new(); + public List AvailableMods { get; private set; } = new(); + readonly Dictionary transientMods = new(); + string availableStatus = "Loading mods from your sources..."; + public string AvailableStatus { get => availableStatus; private set => Set(ref availableStatus, value); } + + public bool IsInCatalog(ModDef def) => cfg.Mods.Contains(def); + + public void EnsureInCatalog(ModVM vm) + { + if (cfg.Mods.Contains(vm.Info)) return; + cfg.Mods.Add(vm.Info); + Mods.Add(vm); + Save(); + } + + static bool Matches(ModDef d, AvailableEntry e) + { + if (d.CvrmgId > 0 && e.Cvrmg != null && e.Cvrmg.Id == d.CvrmgId) return true; + if (d.Source == "github" && d.AssetFilter.Length > 0 && e.Keys.Contains(ModImportService.Norm(d.AssetFilter.Replace(".dll", "", StringComparison.OrdinalIgnoreCase)))) return true; + if (d.FileName.Length > 0 && e.Keys.Contains(ModImportService.Norm(Path.GetFileNameWithoutExtension(d.FileName)))) return true; + return e.Keys.Contains(ModImportService.Norm(d.Name)); + } + + ModDef BuildDef(AvailableEntry e) + { + var def = new ModDef { Name = e.Name, Type = e.Cvrmg?.ModType ?? "Mods" }; + if (e.Repos.FirstOrDefault() is { } o) + { + def.Source = "github"; + def.Repo = o.Repo; + def.AssetFilter = o.Asset; + def.FileName = o.Asset; + def.SourceLink = $"https://github.com/{o.Repo}"; + def.Releases = o.Releases; + } + else if (e.Cvrmg != null) + { + def.Source = "cvrmg"; + def.FileName = Paths.Safe(e.Cvrmg.Name) + ".dll"; + def.Releases = new List { CvrmgService.ToRelease(e.Cvrmg, def.FileName) }; + } + if (e.Cvrmg != null) CvrmgService.ApplyMeta(def, e.Cvrmg); + if (def.Category.Length == 0) def.Category = "Not on CVRMG"; + return def; + } + + public async Task RefreshAvailableAsync(bool force, CancellationToken ct = default) + { + AvailableStatus = "Loading mods from your sources..."; + List entries; + try + { + entries = await AvailableModsService.BuildAsync(cvrmg, importer, cfg.Sources, force, Log, ct); + } + catch (Exception e) when (e is not OperationCanceledException) + { + AvailableStatus = "Could not load sources: " + e.Message; + entries = new(); + } + + var vms = new List(); + var byEntry = new Dictionary(); + var claimed = new HashSet(); + foreach (var e in entries) + { + var existing = cfg.Mods.FirstOrDefault(d => d.Id != ModDef.MelonLoaderId && !claimed.Contains(d) && Matches(d, e)); + if (existing != null) + { + claimed.Add(existing); + if (e.Cvrmg != null) + { + if (existing.CvrmgId == 0) existing.CvrmgId = e.Cvrmg.Id; + if (existing.Category.Length == 0 || existing.Category == "Not on CVRMG") existing.Category = e.Cvrmg.Category; + if (existing.Description.Length == 0) existing.Description = e.Cvrmg.Description; + if (existing.Author.Length == 0) existing.Author = e.Cvrmg.Author; + existing.Status = e.Cvrmg.StatusText; + existing.LatestVersion = e.Cvrmg.ModVersion; + } + if (existing.Source == "github" && e.Repos.FirstOrDefault(o => o.Repo.Equals(existing.Repo, StringComparison.OrdinalIgnoreCase)) is { } offer) + existing.SetReleases(ModImportService.FilterReleases(offer.Releases, existing.AssetFilter)); + else if (existing.Source == "cvrmg" && e.Cvrmg != null) + existing.SetReleases(new List { CvrmgService.ToRelease(e.Cvrmg, existing.FileName.Length > 0 ? existing.FileName : Paths.Safe(e.Cvrmg.Name) + ".dll") }); + var vm = Mods.FirstOrDefault(v => v.Info == existing); + if (vm != null) { vms.Add(vm); byEntry[e] = vm; } + continue; + } + if (e.Cvrmg is { ApprovalStatus: 3 }) continue; + if (!transientMods.TryGetValue(e.Key, out var t)) transientMods[e.Key] = t = new ModVM(this, BuildDef(e)); + else + { + var fresh = BuildDef(e); + t.Info.Releases = fresh.Releases; + t.Info.Description = fresh.Description; + t.Info.LatestVersion = fresh.LatestVersion; + t.Info.Category = fresh.Category; + t.Info.Status = fresh.Status; + } + t.Rebuild(); + vms.Add(t); + byEntry[e] = t; + } + foreach (var vm in Mods.Where(v => v.Info.Id != ModDef.MelonLoaderId && !claimed.Contains(v.Info))) vms.Add(vm); + + foreach (var src in Sources) + { + var own = src.Info.Kind == "cvrmg" + ? entries.Where(e => e.Cvrmg != null) + : entries.Where(e => e.Repos.Any(o => o.Repo.Equals(src.Info.Repo, StringComparison.OrdinalIgnoreCase))); + src.SetMods(own.Select(e => byEntry.GetValueOrDefault(e)).Where(v => v != null).Select(v => v!).Distinct() + .OrderBy(v => v.Info.Name, StringComparer.OrdinalIgnoreCase)); + src.Count = src.Mods.Count; + } + + AvailableMods = vms; + foreach (var vm in Mods) vm.Rebuild(); + Save(); + AvailableStatus = $"{vms.Count} mods from {Sources.Count} sources"; + RegroupMods(); + } + readonly HashSet collapsedMods = new(); + bool regroupQueued; + string modSearch = ""; + string modFilter = "All mods"; + public static string[] ModFilters { get; } = { "All mods", "Installed", "Updates available" }; + + public string ModSearch { get => modSearch; set { if (Set(ref modSearch, value)) RegroupMods(); } } + public string ModFilter { get => modFilter; set { if (value != null && Set(ref modFilter, value)) RegroupMods(); } } + + public void RegroupMods() + { + if (regroupQueued) return; + regroupQueued = true; + Dispatcher.UIThread.Post(() => + { + regroupQueued = false; + var keep = selectedMod; + var q = modSearch.Trim(); + ModRows.Clear(); + var visible = AvailableMods.Where(m => m.Info.Id != ModDef.MelonLoaderId) + .Where(m => q.Length == 0 || m.Name.Contains(q, StringComparison.OrdinalIgnoreCase) || m.Info.Author.Contains(q, StringComparison.OrdinalIgnoreCase) || m.CategoryText.Contains(q, StringComparison.OrdinalIgnoreCase) || m.Info.Description.Contains(q, StringComparison.OrdinalIgnoreCase)) + .Where(m => modFilter switch { "Installed" => m.InstallUsed, "Updates available" => m.UpdateAvailable, _ => true }) + .GroupBy(m => m.CategoryText) + .OrderBy(g => g.Key is "Uncategorized" or "Local files" ? 1 : 0) + .ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase); + foreach (var g in visible) + { + ModRows.Add(new GroupHeader(g.Key, g.Count(), !collapsedMods.Contains(g.Key), h => { if (!collapsedMods.Remove(h.Title)) collapsedMods.Add(h.Title); RegroupMods(); })); + if (collapsedMods.Contains(g.Key)) continue; + foreach (var m in g.OrderBy(m => m.Info.Name, StringComparer.OrdinalIgnoreCase)) ModRows.Add(m); + } + if (keep != null && ModRows.Contains(keep)) selectedMod = keep; + Raise(nameof(SelectedRow)); + }); + } + + public object? SelectedRow + { + get => selectedMod; + set { if (value is ModVM m) SelectedMod = m; } + } + + public ModVM? SelectedMod + { + get => selectedMod; + set { if (Set(ref selectedMod, value)) { Raise(nameof(HasSelectedMod)); Raise(nameof(SelectedRow)); selectedMod?.Rebuild(); } } + } + public bool HasSelectedMod => selectedMod != null; + + public bool ShowNew { get => showNew; set { if (Set(ref showNew, value)) Raise(nameof(ShowDetail)); } } + public bool ShowDetail => Installs.Count > 0; + public string NewName { get => newName; set => Set(ref newName, value); } + public BranchChoice? NewChoice + { + get => newChoice; + set + { + if (value != null) Set(ref newChoice, value); + } + } + public string? NewImportPath { get => newImportPath; set => Set(ref newImportPath, value); } + public bool NewMoveCache { get => newMoveCache; set => Set(ref newMoveCache, value); } + public bool NewDownload { get => newDownload; set => Set(ref newDownload, value); } + + public string? NewDetected + { + get => NewImportPath; + set + { + NewImportPath = value; + if (value == null) return; + var found = SteamIntegration.FindExistingInstalls().FirstOrDefault(x => x.GameDir == value); + if (found.Manifest == null) return; + try + { + var state = Vdf.ParseDocument(File.ReadAllText(found.Manifest)).Dict("AppState"); + var branch = state?.Dict("UserConfig")?.Str("BetaKey"); + if (!string.IsNullOrEmpty(branch)) NewChoice = BranchChoices.FirstOrDefault(c => c.Name == branch) ?? AddChoice(new BranchChoice(branch, "")); + } + catch { } + Raise(); + } + } + + public string AddModRepo { get => addModRepo; set => Set(ref addModRepo, value); } + public string CacheSize { get => cacheSize; private set => Set(ref cacheSize, value); } + + public string LibraryRoot + { + get => cfg.LibraryRoot; + set { if (cfg.LibraryRoot != value) { cfg.LibraryRoot = value; Raise(); } } + } + + public bool CanChangeRoot => cfg.Installs.Count == 0; + + public string SteamUser + { + get => cfg.SteamUser; + set { if (cfg.SteamUser != value) { cfg.SteamUser = value; Raise(); Raise(nameof(SignInUser)); Save(); } } + } + + public bool CheckUpdates + { + get => cfg.CheckUpdates; + set { if (cfg.CheckUpdates != value) { cfg.CheckUpdates = value; Raise(); Save(); } } + } + + UpdateInfo? update; + string updateStatus = ""; + public string VersionText => $"CVR Manager {UpdateService.Current.ToString(3)}"; + public string UpdateStatus { get => updateStatus; private set => Set(ref updateStatus, value); } + public bool UpdateReady => update != null; + public string InstallUpdateText => update == null ? "" : $"Install {update.Tag}"; + public ICommand CheckUpdateCommand => new RelayCommand(() => CheckForUpdateAsync(true)); + public ICommand InstallUpdateCommand => new RelayCommand(InstallUpdateAsync); + + async Task CheckForUpdateAsync(bool manual) + { + if (manual) UpdateStatus = "Checking..."; + try + { + update = await UpdateService.CheckAsync(CancellationToken.None); + UpdateStatus = update != null ? $"{update.Tag} is available." : manual ? "Up to date." : ""; + if (update != null && !manual) ShowToast($"CVR Manager {update.Tag} is available. Install it in Settings."); + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException) + { + update = null; + UpdateStatus = manual ? $"Could not check: {e.Message}" : ""; + } + Raise(nameof(UpdateReady)); + Raise(nameof(InstallUpdateText)); + } + + Task InstallUpdateAsync() + { + var info = update; + if (info == null) return Task.CompletedTask; + if (!UpdateService.CanWriteAppDir()) + { + UpdateStatus = "The app folder is read-only; move CVR Manager somewhere you can write to."; + return Task.CompletedTask; + } + return Run($"Updating to {info.Tag}", async ct => + { + var staged = await UpdateService.DownloadAsync(info, SetProgress, ct); + Save(); + UpdateService.ApplyAndRestart(staged); + Dispatcher.UIThread.Post(() => (Avalonia.Application.Current?.ApplicationLifetime as Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime)?.Shutdown()); + }); + } + + public bool CacheGuard + { + get => cfg.CacheGuard; + set { if (cfg.CacheGuard != value) { cfg.CacheGuard = value; Raise(); Save(); } } + } + + public IEnumerable ThemeNames => ThemeService.Names; + public string ThemeNote { get; private set; } = ""; + + public string Theme + { + get => cfg.Theme; + set + { + if (value == null || cfg.Theme == value) return; + cfg.Theme = value; + var ok = ThemeService.Apply(value); + ThemeNote = ok ? "" : "No saved in-game colours; using ChilloutVR."; + Raise(); + Raise(nameof(ThemeNote)); + Save(); + } + } + + public string GitHubToken + { + get => cfg.GitHubToken; + set { if (cfg.GitHubToken != value) { cfg.GitHubToken = value; Raise(); Save(); } } + } + + public string SteamClientText => SteamIntegration.IsRunning + ? "Steam is running." + : "Steam is started silently on launch."; + + public void Save() => ConfigStore.Save(cfg); + + public void Log(string line) + { + lock (logLock) + { + log.AppendLine(line); + if (log.Length > 60000) log.Remove(0, log.Length - 40000); + if (logRaisePending) return; + logRaisePending = true; + } + Dispatcher.UIThread.Post(async () => + { + await Task.Delay(150); + lock (logLock) logRaisePending = false; + Raise(nameof(LogText)); + }); + } + + void SetProgress(double p) => Dispatcher.UIThread.Post(() => { Indeterminate = false; Progress = p; }); + + async Task Run(string title, Func work) + { + if (IsBusy) { ToastVisible = true; return; } + IsBusy = true; + Status = title; + Indeterminate = true; + cts = new CancellationTokenSource(); + try + { + hookWarning = null; + await work(cts.Token); + if (hookWarning != null) + { + status = $"{title}: done, but {hookWarning}"; + Raise(nameof(Status)); + ShowToast(status, true); + } + else Status = $"{title}: done"; + } + catch (OperationCanceledException) + { + Status = $"{title}: cancelled"; + Log(Status); + } + catch (Exception e) + { + Log($"ERROR: {e.Message}"); + status = $"{title}: failed - {e.Message}"; + Raise(nameof(Status)); + ShowToast(status, true); + } + finally + { + Indeterminate = false; + Progress = 0; + IsBusy = false; + cts = null; + Save(); + foreach (var i in Installs) i.Refresh(); + foreach (var m in Mods) m.Rebuild(); + Raise(nameof(CanChangeRoot)); + } + } + + async Task StartupAsync() + { + DetectExisting(); + AdoptRunningGames(); + _ = RefreshAvailableAsync(false); + var cached = ConfigStore.LoadBranches(); + if (cached.Count > 0) + { + foreach (var b in cached) Branches.Add(b); + RebuildChoices(); + } + _ = RefreshBranchesQuietAsync(CancellationToken.None); + if (cfg.CheckUpdates) _ = CheckForUpdateAsync(false); + if (!IsBusy) await RefreshModCoreAsync(MelonLoader, quiet: true); + _ = Task.Run(() => + { + var size = FileOps.FormatSize(CacheService.SizeOf(Paths.SharedCache)); + Dispatcher.UIThread.Post(() => CacheSize = $"Shared cache size: {size}"); + }); + } + + BranchChoice AddChoice(BranchChoice c) + { + BranchChoices.Add(c); + return c; + } + + public void RebuildChoices() + { + var names = new List { new("public", "") }; + names.AddRange(Branches.Where(b => !b.PasswordRequired && b.Name != "public").Select(b => new BranchChoice(b.Name, ""))); + names.AddRange(cfg.Unlocked.Select(u => new BranchChoice(u.Name, u.Code))); + foreach (var i in cfg.Installs) + if (names.All(n => n.Name != i.Branch)) names.Add(new BranchChoice(i.Branch, i.BranchPassword)); + var keepNew = NewChoice?.Name; + BranchChoices.Clear(); + foreach (var n in names.DistinctBy(n => n.Name)) BranchChoices.Add(n); + newChoice = BranchChoices.FirstOrDefault(c => c.Name == keepNew) ?? BranchChoices.First(); + Raise(nameof(NewChoice)); + foreach (var i in Installs) i.Refresh(); + } + + async Task> CheckCodeAsync(string code) + { + var keys = await BranchCodeService.ResolveAsync(code, cfg.SteamUser, SteamToken, CancellationToken.None); + if (keys.Count == 0) throw new InvalidOperationException("That code does not unlock any ChilloutVR branch."); + foreach (var (n, key) in keys) + { + cfg.Unlocked.RemoveAll(u => u.Name == n); + cfg.Unlocked.Add(new UnlockedBranch { Name = n, Code = code, Key = BranchCodeService.ProtectKey(key) }); + } + Save(); + RebuildChoices(); + Log($"Access code unlocks: {string.Join(", ", keys.Keys)}"); + _ = RefreshBranchesQuietAsync(CancellationToken.None); + return keys.Keys.ToList(); + } + + void DetectExisting() + { + DetectedInstalls.Clear(); + foreach (var (game, _) in SteamIntegration.FindExistingInstalls()) DetectedInstalls.Add(game); + Raise(nameof(HasDetected)); + } + + public Task RefreshBranchesAsync() => Run("Refreshing branches", async ct => + { + var list = await SteamDownloader.GetBranchesAsync(cfg.SteamUser, SteamToken, PrivateBranches(), StoreBranchKey, Log, ct); + Branches.Clear(); + foreach (var b in list) Branches.Add(b); + ConfigStore.SaveBranches(list); + RebuildChoices(); + Log($"Branches: {string.Join(", ", list.Select(b => b.Name + (b.PasswordRequired ? " (password)" : "")))}"); + foreach (var i in Installs) i.Refresh(); + }); + + string SteamToken => TokenVault.Unprotect(cfg.SteamToken); + + void StoreBranchKey(string branch, byte[] key) + { + var entry = cfg.Unlocked.FirstOrDefault(u => u.Name == branch); + if (entry == null) return; + entry.Key = BranchCodeService.ProtectKey(key); + Dispatcher.UIThread.Post(Save); + } + + List PrivateBranches() => + cfg.Unlocked.Where(u => u.Code.Length > 0).Select(u => new SteamDownloader.PrivateBranch(u.Name, u.Code, BranchCodeService.UnprotectKey(u.Key))).ToList(); + + Task InstallFilesAsync(InstallInfo info, bool validate, CancellationToken ct) + { + var key = BranchCodeService.UnprotectKey(cfg.Unlocked.FirstOrDefault(u => u.Name == info.Branch)?.Key ?? ""); + return SteamDownloader.InstallAsync(Paths.GameDir(info), info.Branch, info.BranchPassword, key, StoreBranchKey, + cfg.SteamUser, SteamToken, validate, Log, SetProgress, ct); + } + + async Task CreateInstallAsync() + { + var name = NewName.Trim(); + if (name.Length == 0) { Status = "Give the install a name."; return; } + if (cfg.Installs.Any(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) { Status = "An install with that name exists."; return; } + + var info = new InstallInfo + { + Name = name, + Branch = NewChoice?.Name ?? "public", + BranchPassword = NewChoice?.Code ?? "", + }; + info.Folder = InstallFolders.UniqueFolder(cfg.Installs, info, name); + info.HookIds = cfg.Hooks.Where(h => h.DefaultOn).Select(h => h.Id).ToList(); + var import = NewImport ? NewImportPath : null; + if (NewImport && string.IsNullOrWhiteSpace(import)) { Status = "Choose the folder to import."; return; } + var moveCache = NewMoveCache; + var download = !NewImport; + NewImportDone = NewImport; + + InstallVM? vm = null; + await Run($"Creating {name}", async ct => + { + Directory.CreateDirectory(Paths.GameDir(info)); + cfg.Installs.Add(info); + vm = new InstallVM(this, info); + Installs.Add(vm); + SelectedInstall = vm; + ShowNew = false; + Raise(nameof(CanChangeRoot)); + + if (!string.IsNullOrEmpty(import)) + { + Log($"Mirroring {import} into this install. The original is only read, never changed..."); + var cacheSrc = Path.Combine(import, "ChilloutVR_Data", "Cache"); + await Task.Run(() => FileOps.CopyTree(import, Paths.GameDir(info), + p => string.Equals(p, cacheSrc, StringComparison.OrdinalIgnoreCase), Log, ct), ct); + + var found = SteamIntegration.FindExistingInstalls().FirstOrDefault(x => x.GameDir == import); + if (found.Manifest != null) + { + Directory.CreateDirectory(Path.GetDirectoryName(Paths.Manifest(info))!); + File.Copy(found.Manifest, Paths.Manifest(info), true); + } + + if (moveCache && Directory.Exists(cacheSrc)) + { + Log("Copying the source cache into the shared cache (the original is left as it is)..."); + await Task.Run(() => CacheService.CopyInto(cacheSrc, Paths.SharedCache, Log, ct), ct); + } + } + + if (download) await InstallFilesAsync(info, true, ct); + if (Directory.Exists(Path.GetDirectoryName(Paths.CacheDir(info))!) || File.Exists(Paths.GameExe(info))) + await CacheService.LinkAsync(info, Log, ct); + if (NewImportDone) await ImportModsCoreAsync(info, ct); + if (File.Exists(Paths.GameExe(info))) await RunHooksAsync(vm, HookDef.AfterUpdateEvent, ct); + }); + vm?.Refresh(); + NewName = ""; + NewImportPath = null; + } + + public Task UpdateInstallAsync(InstallVM vm, bool validate) => Run($"Updating {vm.Info.Name}", async ct => + { + await RunHooksAsync(vm, HookDef.BeforeUpdateEvent, ct); + await InstallFilesAsync(vm.Info, validate, ct); + await CacheService.LinkAsync(vm.Info, Log, ct); + if (vm.Info.Mods.Count > 0) await store.ApplyAsync(vm.Info, cfg.Mods, Log, ct); + vm.ClearPending(); + await RunHooksAsync(vm, HookDef.AfterUpdateEvent, ct); + await RefreshBranchesQuietAsync(ct); + }); + + async Task RefreshBranchesQuietAsync(CancellationToken ct) + { + try + { + var list = await SteamDownloader.GetBranchesAsync(cfg.SteamUser, SteamToken, PrivateBranches(), StoreBranchKey, Log, ct); + Branches.Clear(); + foreach (var b in list) Branches.Add(b); + ConfigStore.SaveBranches(list); + RebuildChoices(); + } + catch (OperationCanceledException) { throw; } + catch (Exception e) { Log($"Branch refresh failed: {e.Message}"); } + } + + public Task LinkCacheAsync(InstallVM vm) => Run("Linking cache", async ct => + { + await CacheService.LinkAsync(vm.Info, Log, ct); + }); + + Task DedupeAllAsync() => Run("Deduplicating caches", async ct => + { + foreach (var i in Installs.Where(i => CacheService.GetState(i.Info) != CacheState.Missing).ToList()) + { + Log($"{i.Info.Name}: {CacheService.GetState(i.Info)}"); + await CacheService.LinkAsync(i.Info, Log, ct); + } + var size = await Task.Run(() => CacheService.SizeOf(Paths.SharedCache), ct); + CacheSize = $"Shared cache size: {FileOps.FormatSize(size)}"; + }); + + public void OpenFolder(InstallVM vm) + { + var path = Directory.Exists(Paths.GameDir(vm.Info)) ? Paths.GameDir(vm.Info) : Paths.InstallDir(vm.Info); + Directory.CreateDirectory(path); + Explorer(path); + } + + static void Explorer(string path) + { + Directory.CreateDirectory(path); + Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = true }); + } + + public void MoveInstall(InstallVM vm, int to) + { + var from = Installs.IndexOf(vm); + if (from < 0 || to < 0 || to >= Installs.Count || from == to) return; + Installs.Move(from, to); + cfg.Installs.RemoveAt(from); + cfg.Installs.Insert(to, vm.Info); + if (selectedInstall == vm) Raise(nameof(SelectedInstall)); + else SelectedInstall = vm; + } + + public Task DuplicateInstallAsync(InstallVM src) => Run($"Duplicating {src.Info.Name}", async ct => + { + var srcGame = Paths.GameDir(src.Info); + if (!Directory.Exists(srcGame) || !File.Exists(Paths.GameExe(src.Info))) + throw new InvalidOperationException("There is nothing to duplicate yet: this install has no game files."); + if (src.IsRunning || FindGameProcesses(src.Info).Count > 0) + throw new InvalidOperationException("Stop the game before duplicating its install."); + + var name = $"{src.Info.Name} copy"; + for (var n = 2; cfg.Installs.Any(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); n++) + name = $"{src.Info.Name} copy {n}"; + + var info = new InstallInfo + { + Name = name, + Branch = src.Info.Branch, + BranchPassword = src.Info.BranchPassword, + LaunchArgs = src.Info.LaunchArgs, + LaunchMode = src.Info.LaunchMode, + AccountId = src.Info.AccountId, + HookIds = src.Info.HookIds.ToList(), + Mods = src.Info.Mods.Select(a => new ModAssignment { ModId = a.ModId, Version = a.Version, Enabled = a.Enabled }).ToList(), + Deployed = src.Info.Deployed.ToDictionary(kv => kv.Key, kv => kv.Value.ToList()), + }; + info.Folder = InstallFolders.UniqueFolder(cfg.Installs, info, name); + + var skip = new[] + { + Path.GetFullPath(Paths.CacheDir(src.Info)), + Path.GetFullPath(Path.Combine(srcGame, "MelonLoader", "Logs")), + Path.GetFullPath(Path.Combine(srcGame, "MelonLoader", "Latest.log")), + }; + Log($"Copying {src.Info.Name} to {name} (cache and logs are not copied; the cache is shared)..."); + try + { + await Task.Run(() => FileOps.CopyTree(srcGame, Paths.GameDir(info), + p => skip.Contains(Path.GetFullPath(p), StringComparer.OrdinalIgnoreCase), Log, ct), ct); + await CacheService.LinkAsync(info, Log, ct); + } + catch + { + await Task.Run(() => FileOps.DeleteDirectorySafe(Paths.InstallDir(info))); + throw; + } + + cfg.Installs.Add(info); + var vm = new InstallVM(this, info); + Installs.Add(vm); + SelectedInstall = vm; + Raise(nameof(CanChangeRoot)); + Log($"Created {name}."); + }); + + public async Task DeleteInstallAsync(InstallVM vm) + { + if (Confirm != null && !await Confirm($"Delete install '{vm.Info.Name}' and all its game files?\nThe shared cache and downloaded mods are kept.")) return; + await Run($"Deleting {vm.Info.Name}", async ct => + { + await Task.Run(() => + { + CacheService.RemoveLinkIfJunction(Paths.CacheDir(vm.Info)); + FileOps.DeleteDirectorySafe(Paths.InstallDir(vm.Info)); + }, ct); + cfg.Installs.Remove(vm.Info); + Installs.Remove(vm); + SelectedInstall = Installs.FirstOrDefault(); + }); + } + + readonly HashSet launching = new(); + + public async Task LaunchAsync(InstallVM vm, string? modeKey = null) + { + var exe = Paths.GameExe(vm.Info); + if (!File.Exists(exe)) { Status = "Nothing to launch: update this install first."; return; } + if (!launching.Add(vm)) return; + try { await LaunchCoreAsync(vm, exe, modeKey); } + finally { launching.Remove(vm); } + } + + async Task LaunchCoreAsync(InstallVM vm, string exe, string? modeKey) + { + Status = $"Preparing {vm.Info.Name}..."; + if (vm.Pending) await ApplyModsAsync(vm); + + var hookFailures = await HookService.RunEventAsync(cfg.Hooks, vm.Info, HookDef.BeforeLaunchEvent, vm.InstalledBuild, Log, CancellationToken.None); + if (hookFailures.Count > 0) Log($"Launching anyway; hook problems: {string.Join(", ", hookFailures)}"); + + var steamOk = await SteamIntegration.EnsureRunningAsync(Log); + if (!steamOk) Log("WARNING: the Steam client is not running, so Steam will not count this session."); + + var gameDir = Paths.GameDir(vm.Info); + await Task.Run(() => + { + try { MultiInstance.Prepare(gameDir, Log); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { Log($"Could not prepare the install for several instances: {e.Message}"); } + + var appIdFile = Path.Combine(gameDir, "steam_appid.txt"); + if (!File.Exists(appIdFile)) File.WriteAllText(appIdFile, Paths.AppId.ToString()); + + try + { + if (cfg.CacheGuard) CacheGuardService.Deploy(gameDir, Log); + else CacheGuardService.Remove(gameDir, Log); + } + catch (Exception e) { Log($"Cache guard step failed: {e.Message}"); } + }); + + var psi = new ProcessStartInfo(exe) + { + WorkingDirectory = Paths.GameDir(vm.Info), + UseShellExecute = false, + }; + psi.Environment["SteamAppId"] = Paths.AppId.ToString(); + psi.Environment["SteamGameId"] = Paths.AppId.ToString(); + var mode = LaunchModeOption.Find(modeKey ?? vm.Info.LaunchMode); + var extra = SplitArgs(mode.Arguments).Concat(SplitArgs(vm.Info.LaunchArgs)).ToList(); + var account = cfg.Accounts.FirstOrDefault(a => a.Id == vm.Info.AccountId); + if (account != null) + { + try { extra.Add("--profile=" + await Task.Run(() => AccountService.WriteProfile(gameDir, account))); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or InvalidOperationException) { Log($"ERROR: {e.Message}"); Status = e.Message; return; } + } + foreach (var arg in extra) psi.ArgumentList.Add(arg); + + Process proc; + try { proc = await Task.Run(() => Process.Start(psi)!); } + catch (Exception e) { Log($"ERROR: could not start the game: {e.Message}"); return; } + + var instanceNo = vm.InstanceCount + 1; + Status = instanceNo > 1 ? $"Launched another instance of {vm.Info.Name} ({instanceNo} running)" : $"Launched {vm.Info.Name}"; + Log($"Launched {vm.Info.Name}, launch mode: {mode.Label}{(account != null ? $", account: {account.Name}" : "")} (pid {proc.Id}, instance {instanceNo})."); + _ = Task.Run(() => GameWindow.ApplyIconWhenReadyAsync(proc, exe)); + Watch(vm, proc, checkSteam: true); + } + + readonly HashSet trackedPids = new(); + + void Watch(InstallVM vm, Process proc, bool checkSteam) + { + var pid = proc.Id; + trackedPids.Add(pid); + vm.InstanceStarted(); + var wasRunning = SteamIntegration.IsAppRunning; + _ = Task.Run(async () => + { + if (checkSteam) + { + for (var i = 0; i < 40 && !proc.HasExited && !SteamIntegration.IsAppRunning; i++) await Task.Delay(1000); + if (proc.HasExited) { } + else if (SteamIntegration.IsAppRunning) + Log(wasRunning ? "Steam already showed ChilloutVR as running before this launch." : "Steam registered the session as ChilloutVR; playtime is being counted."); + else + Log("Steam has not registered the game as running; playtime may not be counted."); + } + await proc.WaitForExitAsync(); + Dispatcher.UIThread.Post(() => + { + trackedPids.Remove(pid); + vm.InstanceEnded(); + Log($"{vm.Info.Name} exited (pid {pid})."); + if (vm.InstanceCount == 0 && renamePending.Remove(vm)) TryRenameFolder(vm); + vm.Refresh(); + }); + }); + } + + static List FindGameProcesses(InstallInfo info) + { + var exe = Path.GetFullPath(Paths.GameExe(info)); + var found = new List(); + foreach (var p in Process.GetProcessesByName("ChilloutVR")) + { + try + { + if (string.Equals(p.MainModule?.FileName, exe, StringComparison.OrdinalIgnoreCase)) { found.Add(p); continue; } + } + catch (Exception e) when (e is System.ComponentModel.Win32Exception or InvalidOperationException) { } + p.Dispose(); + } + return found; + } + + readonly Dictionary renameTimers = new(); + readonly HashSet renamePending = new(); + + bool TryRenameFolder(InstallVM vm) + { + var info = vm.Info; + var desired = InstallFolders.UniqueFolder(cfg.Installs, info, info.Name); + if (desired.Equals(info.Folder, StringComparison.Ordinal)) return true; + + if (vm.IsRunning || FindGameProcesses(info).Count > 0) + { + renamePending.Add(vm); + return false; + } + var before = info.Folder; + try + { + InstallFolders.Rename(info, desired); + info.Folder = desired; + Save(); + vm.Refresh(); + Log($"Renamed the install folder '{before}' to '{desired}'."); + return true; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + Log($"Could not rename the folder of '{info.Name}' ({e.Message})."); + return false; + } + } + + public void ScheduleFolderRename(InstallVM vm) + { + if (renameTimers.TryGetValue(vm, out var previous)) previous.Cancel(); + var cts = renameTimers[vm] = new CancellationTokenSource(); + var token = cts.Token; + _ = Task.Run(async () => + { + try + { + await Task.Delay(1200, token); + while (IsBusy) await Task.Delay(1000, token); + } + catch (OperationCanceledException) { return; } + Dispatcher.UIThread.Post(() => + { + if (token.IsCancellationRequested) return; + renameTimers.Remove(vm); + if (!TryRenameFolder(vm) && vm.IsRunning) + Status = $"The folder will be renamed after {vm.Info.Name} is closed."; + }); + }); + } + + void AdoptRunningGames() + { + foreach (var vm in Installs) + { + foreach (var p in FindGameProcesses(vm.Info)) + { + if (trackedPids.Contains(p.Id)) { p.Dispose(); continue; } + Log($"{vm.Info.Name} is already running (pid {p.Id}); adopted it."); + var adopted = p; + _ = Task.Run(() => GameWindow.ApplyIconWhenReadyAsync(adopted, Paths.GameExe(vm.Info))); + Watch(vm, p, checkSteam: false); + } + } + } + + public async Task StopAsync(InstallVM vm) + { + var targets = FindGameProcesses(vm.Info); + if (targets.Count == 0) + { + vm.ResetInstances(); + Status = $"{vm.Info.Name} is not running."; + return; + } + Log($"Stopping {vm.Info.Name} ({targets.Count} instance{(targets.Count == 1 ? "" : "s")})..."); + Status = $"Stopping {vm.Info.Name}..."; + foreach (var p in targets) + { + try { p.CloseMainWindow(); } catch (InvalidOperationException) { } + } + var deadline = DateTime.UtcNow.AddSeconds(8); + while (DateTime.UtcNow < deadline && targets.Any(p => !p.HasExited)) await Task.Delay(250); + foreach (var p in targets.Where(p => !p.HasExited)) + { + Log($"{vm.Info.Name} did not close in time; ending the process."); + try { p.Kill(true); } catch (Exception e) when (e is InvalidOperationException or System.ComponentModel.Win32Exception) { } + } + foreach (var p in targets) p.Dispose(); + } + + static IEnumerable SplitArgs(string s) + { + var sb = new StringBuilder(); + var quoted = false; + foreach (var c in s) + { + if (c == '"') quoted = !quoted; + else if (char.IsWhiteSpace(c) && !quoted) + { + if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); } + } + else sb.Append(c); + } + if (sb.Length > 0) yield return sb.ToString(); + } + + public Task ApplyModsAsync(InstallVM vm) => Run($"Applying mods to {vm.Info.Name}", async ct => + { + await store.ApplyAsync(vm.Info, cfg.Mods, Log, ct); + vm.ClearPending(); + }); + + public Task ToggleMelonLoaderAsync(InstallVM vm) + { + var game = Paths.GameDir(vm.Info); + var dll = Path.Combine(game, "version.dll"); + var off = dll + ".off"; + var a = vm.MlAssignment; + + if (vm.MlPresent) + { + if (a != null) { a.Enabled = false; return ApplyModsAsync(vm); } + return Run("Disabling MelonLoader", _ => { File.Move(dll, off, true); return Task.CompletedTask; }); + } + if (a != null) { a.Enabled = true; return ApplyModsAsync(vm); } + if (vm.MlOffPresent) return Run("Enabling MelonLoader", _ => { File.Move(off, dll, true); return Task.CompletedTask; }); + return SetMelonLoaderVersionAsync(vm); + } + + public Task SetMelonLoaderVersionAsync(InstallVM vm) + { + var tag = vm.MlVersionChoice; + if (tag == null) { Status = "Pick a MelonLoader version first (the app never guesses one)."; return Task.CompletedTask; } + var a = vm.MlAssignment; + if (a == null) vm.Info.Mods.Add(a = new ModAssignment { ModId = ModDef.MelonLoaderId }); + a.Version = tag; + a.Enabled = true; + return Run("Installing MelonLoader", async ct => + { + if (MelonLoader.Releases.Count == 0) await RefreshModCoreAsync(MelonLoader, true, ct); + await store.ApplyAsync(vm.Info, cfg.Mods, Log, ct); + vm.ClearPending(); + }); + } + + async Task RefreshModCoreAsync(ModDef mod, bool quiet = false, CancellationToken ct = default) + { + if (mod.Source == "local" || mod.Local) return; + try + { + if (mod.Source == "cvrmg") + { + var all = await cvrmg.GetAllAsync(ct); + var m = all.FirstOrDefault(x => x.Id == mod.CvrmgId) ?? throw new InvalidOperationException("no longer listed on CVRMG"); + CvrmgService.ApplyMeta(mod, m); + mod.SetReleases(new List { CvrmgService.ToRelease(m, mod.FileName.Length > 0 ? mod.FileName : Paths.Safe(m.Name) + ".dll") }); + } + else + { + var fresh = await github.GetReleasesAsync(mod.Repo, ct); + var filtered = ModImportService.FilterReleases(fresh, mod.AssetFilter).ToList(); + if (mod.Id == ModDef.MelonLoaderId) + { + try + { + if (await github.GetMelonLoaderNightlyAsync(ct) is { } nightly) filtered.Insert(0, nightly); + } + catch (Exception e) when (e is not OperationCanceledException) { Log($"MelonLoader nightly: could not look up ({e.Message})"); } + } + mod.SetReleases(filtered); + } + if (!quiet) Log($"{mod.Name}: {mod.Releases.Count} versions, latest {mod.LatestTag}"); + } + catch (Exception e) when (quiet && e is not OperationCanceledException) + { + Log($"{mod.Name}: could not fetch versions ({e.Message})"); + } + Dispatcher.UIThread.Post(() => + { + Mods.FirstOrDefault(m => m.Info == mod)?.Rebuild(); + foreach (var i in Installs) i.Refresh(); + RegroupMods(); + Save(); + }); + } + + public async Task SwitchSourceAsync(ModVM vm, string source) + { + var def = vm.Info; + if (def.Source == source) return; + var previous = def.Source; + def.Source = source; + def.Releases = def.Releases.Where(r => r.Snapshot).ToList(); + try + { + if (source == "cvrmg") + { + var m = (await cvrmg.GetAllAsync(CancellationToken.None)).FirstOrDefault(x => x.Id == def.CvrmgId) + ?? throw new InvalidOperationException("this mod is not listed on CVRMG"); + if (def.FileName.Length == 0) def.FileName = Paths.Safe(def.Name) + ".dll"; + CvrmgService.ApplyMeta(def, m); + def.SetReleases(new List { CvrmgService.ToRelease(m, def.FileName) }); + } + else + { + var releases = await importer.ReleasesAsync(def.Repo, Log, CancellationToken.None); + def.SetReleases(ModImportService.FilterReleases(releases, def.AssetFilter)); + } + } + catch (Exception e) + { + Log($"{def.Name}: could not switch to {source} ({e.Message})"); + def.Source = previous; + } + + foreach (var inst in Installs) + if (inst.Info.Mods.FirstOrDefault(a => a.ModId == def.Id) is { } a && a.Version != "latest" && def.Releases.All(r => r.Tag != a.Version)) + a.Version = "latest"; + + vm.Rebuild(); + if (cfg.Mods.Contains(def)) Save(); + } + + public Task RefreshModAsync(ModVM vm) => Run($"Refreshing {vm.Info.Name}", ct => RefreshModCoreAsync(vm.Info, ct: ct)); + + Task RefreshAllModsAsync() => Run("Refreshing mod sources", ct => RefreshAvailableAsync(true, ct)); + + async Task AddModAsync() + { + var repo = GitHubService.ParseRepo(AddModRepo); + if (repo == null) { Status = "Enter a GitHub repo as owner/repo or a github.com URL."; return; } + if (cfg.Mods.Any(m => m.Repo.Equals(repo, StringComparison.OrdinalIgnoreCase))) { Status = "That repo is already in the catalog."; return; } + var def = new ModDef { Name = repo.Split('/')[1], Repo = repo }; + await Run($"Adding {repo}", async ct => + { + await RefreshModCoreAsync(def, ct: ct); + cfg.Mods.Add(def); + var vm = new ModVM(this, def); + Mods.Add(vm); + SelectedMod = vm; + AddModRepo = ""; + }); + } + + void SyncModList() + { + foreach (var def in cfg.Mods.Where(d => Mods.All(v => v.Info != d)).ToList()) Mods.Add(new ModVM(this, def)); + foreach (var m in Mods) m.Rebuild(); + _ = RefreshAvailableAsync(false); + } + + async Task ImportModsCoreAsync(InstallInfo info, CancellationToken ct) + { + var found = await importer.IdentifyAsync(Paths.GameDir(info), cfg.Sources, Log, ct); + var added = await Task.Run(() => importer.Apply(cfg, info, found, Log), ct); + int Count(string k) => found.Count(f => f.Source == k); + Log($"Imported {added} mods: {Count("github")} from GitHub repos, {Count("cvrmg")} from CVRMG, {Count("local")} kept as local snapshots."); + SyncModList(); + } + + public Task ImportInstallModsAsync(InstallVM vm) => Run($"Importing mods from {vm.Info.Name}", async ct => + { + await ImportModsCoreAsync(vm.Info, ct); + vm.Rebuild(); + vm.ClearPending(); + }); + + public ObservableCollection Sources { get; } = new(); + string addSourceRepo = ""; + public string AddSourceRepo { get => addSourceRepo; set => Set(ref addSourceRepo, value); } + + public ICommand ScanSteamInstallCommand => new RelayCommand(() => Run("Scanning your Steam install's mods", async ct => + { + var dir = DetectedInstalls.FirstOrDefault() ?? throw new InvalidOperationException("No Steam install of ChilloutVR was found."); + var found = await importer.IdentifyAsync(dir, cfg.Sources, Log, ct); + var added = await Task.Run(() => importer.Apply(cfg, null, found, Log, dir), ct); + int Count(string k) => found.Count(f => f.Source == k); + Log($"Catalog updated from {dir}: {added} mods ({Count("github")} from GitHub repos, {Count("cvrmg")} from CVRMG, {Count("local")} local snapshots)."); + SyncModList(); + })); + + public ICommand AddSourceCommand => new RelayCommand(() => + { + var repo = GitHubService.ParseRepo(AddSourceRepo); + if (repo == null) { Status = "Enter a GitHub repo as owner/repo or a github.com URL."; return; } + if (cfg.Sources.Any(s => s.Repo.Equals(repo, StringComparison.OrdinalIgnoreCase))) { Status = "That source is already listed."; return; } + var def = new SourceDef { Name = repo.Split('/')[1], Kind = "github", Repo = repo }; + cfg.Sources.Add(def); + var sv = new SourceVM(this, def); + Sources.Add(sv); + SelectedSource = sv; + AddSourceRepo = ""; + Save(); + _ = RefreshAvailableAsync(false); + }); + + public void RemoveSource(SourceVM vm) + { + if (!vm.CanRemove) return; + cfg.Sources.Remove(vm.Info); + Sources.Remove(vm); + if (SelectedSource == vm) SelectedSource = Sources.FirstOrDefault(); + Save(); + _ = RefreshAvailableAsync(false); + } + + SourceVM? selectedSource; + public SourceVM? SelectedSource { get => selectedSource; set { if (Set(ref selectedSource, value)) Raise(nameof(HasSelectedSource)); } } + public bool HasSelectedSource => selectedSource != null; + + public async Task AddLocalFilesAsync(ModVM? vm = null) + { + if (PickFiles == null) return; + var files = await PickFiles(); + if (files.Count == 0) return; + await Run("Importing local mod", ct => + { + var def = vm?.Info; + if (def == null) + { + def = new ModDef { Name = Path.GetFileNameWithoutExtension(files[0]), Local = true }; + def.Releases.Add(new ReleaseInfo { Tag = "local", Published = DateTime.Now }); + cfg.Mods.Add(def); + var nv = new ModVM(this, def); + Mods.Add(nv); + SelectedMod = nv; + } + store.ImportLocal(def, files); + return Task.CompletedTask; + }); + } + + public Task AddLocalFilesNewAsync() => AddLocalFilesAsync(); + + public Task DownloadModAsync(ModVM vm) => Run($"Downloading {vm.Info.Name}", async ct => + { + var tag = vm.SelectedRelease?.Tag ?? vm.Info.LatestTag; + if (tag == null) throw new InvalidOperationException("No version selected; refresh versions first."); + await store.EnsureDownloadedAsync(vm.Info, tag, Log, ct); + }); + + public async Task DeleteModDownloadAsync(ModVM vm) + { + var tag = vm.SelectedRelease?.Tag; + if (tag == null) return; + await Run($"Deleting {vm.Info.Name} {tag}", _ => { ModStore.DeleteVersion(vm.Info, tag); return Task.CompletedTask; }); + } + + public async Task RemoveModAsync(ModVM vm) + { + if (vm.IsBuiltin) return; + var users = Installs.Where(i => i.Info.Mods.Any(a => a.ModId == vm.Info.Id)).ToList(); + if (Confirm != null && !await Confirm(users.Count == 0 + ? $"Remove {vm.Info.Name} from the catalog and delete its downloads?" + : $"Remove {vm.Info.Name}? It is assigned to: {string.Join(", ", users.Select(u => u.Info.Name))}. Its files stay in those installs until you apply mods there.")) return; + await Run($"Removing {vm.Info.Name}", ct => + { + foreach (var u in users) + { + u.Info.Mods.RemoveAll(a => a.ModId == vm.Info.Id); + u.Rebuild(); + u.MarkPending(); + } + cfg.Mods.Remove(vm.Info); + Mods.Remove(vm); + SelectedMod = Mods.FirstOrDefault(); + _ = RefreshAvailableAsync(false); + var dir = Path.Combine(Paths.ModStore, vm.Info.Id); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + return Task.CompletedTask; + }); + } + + async Task BrowseLibraryRootAsync() + { + if (PickFolder == null) return; + var p = await PickFolder(); + if (p != null) LibraryRoot = p; + } + + async Task BrowseImportAsync() + { + if (PickFolder == null) return; + var p = await PickFolder(); + if (p != null) NewImportPath = p; + } + + void ApplyLibraryRoot() + { + if (!CanChangeRoot) { Status = "Delete all installs before changing the library location."; return; } + Paths.Root = cfg.LibraryRoot; + Save(); + Directory.CreateDirectory(Paths.Root); + Status = $"Library location set to {Paths.Root}"; + } +} diff --git a/Views/LogView.cs b/Views/LogView.cs new file mode 100644 index 0000000..51f5154 --- /dev/null +++ b/Views/LogView.cs @@ -0,0 +1,453 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Media; +using Avalonia.Threading; + +namespace CVRManager.Views; + +public class LogView : UserControl +{ + public static readonly StyledProperty TextProperty = AvaloniaProperty.Register(nameof(Text)); + + readonly ScrollViewer scroll; + readonly LogSurface surface = new(); + + static LogView() => TextProperty.Changed.AddClassHandler((c, _) => c.OnTextChanged()); + + public LogView() + { + scroll = new ScrollViewer + { + Content = surface, + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + }; + scroll.ScrollChanged += (_, _) => surface.UpdateView(scroll.Offset, scroll.Viewport); + Content = new Border + { + Background = new SolidColorBrush(Color.Parse("#0B0D10")), + BorderBrush = new SolidColorBrush(Color.Parse("#232833")), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(8), + ClipToBounds = true, + Child = scroll, + }; + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + void OnTextChanged() + { + var atEnd = scroll.Extent.Height <= 0 || scroll.Offset.Y + scroll.Viewport.Height >= scroll.Extent.Height - 24; + if (!surface.SetText(Text ?? "")) return; + if (atEnd) Dispatcher.UIThread.Post(() => scroll.ScrollToEnd(), DispatcherPriority.Background); + } +} + +internal sealed class LogSurface : Control +{ + const int MaxLines = 20000; + const int KeepLines = 15000; + + static readonly IBrush DefaultBrush = new SolidColorBrush(Color.Parse("#E6E8EC")); + static readonly IBrush TimeBrush = new SolidColorBrush(Color.Parse("#6B7585")); + static readonly IBrush TagBrush = new SolidColorBrush(Color.Parse("#7AA2FF")); + static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#F0646A")); + static readonly IBrush WarnBrush = new SolidColorBrush(Color.Parse("#F5B13D")); + static readonly IBrush SelectionBrush = new SolidColorBrush(Color.Parse("#553A5BA8")); + + static readonly Regex TimeRx = new(@"^(\[\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?\]|\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?)(\s*)(.*)$", RegexOptions.Compiled); + static readonly Regex TagRx = new(@"^(\s*(?:\[[^\]]{1,40}\]|\([^)]{1,100}\)))(.*)$", RegexOptions.Compiled); + static readonly Regex StackRx = new(@"^\s+at\s", RegexOptions.Compiled); + static readonly Regex FileLineRx = new(@"^\(Filename:", RegexOptions.Compiled); + static readonly Regex PipeLevelRx = new(@"\|\s*(?error|exception|fatal|warn|warning|info|debug|trace)\s*\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + static readonly Regex BracketLevelRx = new(@"\[(?error|fatal|fail|exception|warn|warning)\]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + static readonly Regex LeadingLevelRx = new(@"^\s*(?error|fatal|unhandled exception|exception|failed|failure|warning|warn)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); + static readonly Regex ExceptionRx = new(@"(?:^|\s)(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*Exception:", RegexOptions.Compiled); + + readonly record struct Seg(string Text, IBrush Brush); + + static readonly Regex AnsiRx = new("\u001b\\[([0-9;]*)m", RegexOptions.Compiled); + static readonly Regex OtherEscRx = new("\u001b\\[[0-9;?]*[A-Za-ln-z]", RegexOptions.Compiled); + static readonly IBrush[] AnsiNormal = new[] { "#8B93A1", "#F0646A", "#4ADE9B", "#F5B13D", "#7AA2FF", "#C792EA", "#5FD3E0", "#E6E8EC" } + .Select(c => (IBrush)new SolidColorBrush(Color.Parse(c))).ToArray(); + static readonly IBrush[] AnsiBright = new[] { "#A0A8B5", "#FF8087", "#6EF0B5", "#FFD166", "#9AB8FF", "#DDA8FF", "#8FEAF5", "#FFFFFF" } + .Select(c => (IBrush)new SolidColorBrush(Color.Parse(c))).ToArray(); + + static IBrush Rgb(int r, int g, int b) => new SolidColorBrush(Color.FromRgb((byte)Math.Clamp(r, 0, 255), (byte)Math.Clamp(g, 0, 255), (byte)Math.Clamp(b, 0, 255))); + + static IBrush? ApplySgr(string codes, IBrush? current) + { + var parts = codes.Length == 0 ? new[] { "0" } : codes.Split(';'); + for (var i = 0; i < parts.Length; i++) + { + if (!int.TryParse(parts[i], out var n)) continue; + if (n == 0 || n == 39) current = null; + else if (n >= 30 && n <= 37) current = AnsiNormal[n - 30]; + else if (n >= 90 && n <= 97) current = AnsiBright[n - 90]; + else if (n == 38 && i + 1 < parts.Length) + { + if (parts[i + 1] == "2" && i + 4 < parts.Length && int.TryParse(parts[i + 2], out var r) && int.TryParse(parts[i + 3], out var g) && int.TryParse(parts[i + 4], out var b)) + { + current = Rgb(r, g, b); + i += 4; + } + else if (parts[i + 1] == "5" && i + 2 < parts.Length && int.TryParse(parts[i + 2], out var idx)) + { + if (idx < 8) current = AnsiNormal[idx]; + else if (idx < 16) current = AnsiBright[idx - 8]; + else if (idx < 232) + { + var c = idx - 16; + int Level(int v) => v == 0 ? 0 : 55 + v * 40; + current = Rgb(Level(c / 36), Level(c / 6 % 6), Level(c % 6)); + } + else { var v = 8 + (idx - 232) * 10; current = Rgb(v, v, v); } + i += 2; + } + } + } + return current; + } + + static Line ParseAnsi(string raw, IBrush? carried) + { + raw = OtherEscRx.Replace(raw, ""); + var segs = new List(4); + var brush = carried; + var pos = 0; + foreach (Match m in AnsiRx.Matches(raw)) + { + if (m.Index > pos) segs.Add(new Seg(raw[pos..m.Index], brush ?? DefaultBrush)); + brush = ApplySgr(m.Groups[1].Value, brush); + pos = m.Index + m.Length; + } + if (pos < raw.Length) segs.Add(new Seg(raw[pos..], brush ?? DefaultBrush)); + return new Line { Text = string.Concat(segs.Select(s => s.Text)), Segs = segs.ToArray(), AnsiEnd = brush }; + } + + sealed class Line + { + public IBrush? AnsiEnd; + public string Text = ""; + public Seg[] Segs = Array.Empty(); + public IBrush? Level; + } + + readonly List lines = new(); + readonly Dictionary formatted = new(); + readonly Typeface typeface = new(new FontFamily("Cascadia Mono,Consolas,monospace")); + readonly double fontSize = 12; + readonly Thickness pad = new(12, 8); + readonly double charWidth; + readonly double lineHeight; + + string shown = ""; + int maxCols; + Vector offset; + Size viewport; + (int Line, int Col)? anchor; + (int Line, int Col)? caret; + bool dragging; + + public LogSurface() + { + var probe = Format("0000000000", DefaultBrush); + charWidth = probe.Width / 10; + lineHeight = Math.Ceiling(probe.Height) + 1; + Focusable = true; + Cursor = new Cursor(StandardCursorType.Ibeam); + ClipToBounds = true; + } + + FormattedText Format(string text, IBrush brush) => + new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, fontSize, brush); + + static IBrush? FromWord(string word) => word.ToLowerInvariant() switch + { + "error" or "fatal" or "fail" or "exception" or "failed" or "failure" or "unhandled exception" => ErrorBrush, + "warn" or "warning" => WarnBrush, + _ => null, + }; + + static IBrush? LevelOfLine(string line) + { + var pipe = PipeLevelRx.Match(line); + if (pipe.Success) return FromWord(pipe.Groups["l"].Value); + + var head = line.Length > 160 ? line[..160] : line; + var bracket = BracketLevelRx.Match(head); + if (bracket.Success) return FromWord(bracket.Groups["l"].Value); + + var message = line; + var time = TimeRx.Match(message); + if (time.Success) message = time.Groups[3].Value; + var tag = TagRx.Match(message); + while (tag.Success) + { + message = tag.Groups[2].Value; + tag = TagRx.Match(message); + } + + var leading = LeadingLevelRx.Match(message); + if (leading.Success) return FromWord(leading.Groups["l"].Value); + return ExceptionRx.IsMatch(message) ? ErrorBrush : null; + } + + static Line Parse(string text, IBrush? inherited) + { + var line = new Line { Text = text, Level = inherited }; + var segs = new List(4); + if (StackRx.IsMatch(text)) segs.Add(new Seg(text, inherited ?? DefaultBrush)); + else if (FileLineRx.IsMatch(text)) segs.Add(new Seg(text, TimeBrush)); + else + { + var level = LevelOfLine(text); + line.Level = level; + var rest = text; + var time = TimeRx.Match(rest); + if (time.Success) + { + segs.Add(new Seg(time.Groups[1].Value, TimeBrush)); + if (time.Groups[2].Length > 0) segs.Add(new Seg(time.Groups[2].Value, DefaultBrush)); + rest = time.Groups[3].Value; + } + var tag = TagRx.Match(rest); + if (tag.Success) + { + segs.Add(new Seg(tag.Groups[1].Value, level ?? TagBrush)); + rest = tag.Groups[2].Value; + } + if (rest.Length > 0) segs.Add(new Seg(rest, level ?? DefaultBrush)); + } + line.Segs = segs.ToArray(); + return line; + } + + void AppendLines(string chunk) + { + var parts = chunk.Split('\n'); + var count = parts.Length; + if (count > 0 && parts[^1].Length == 0) count--; + for (var i = 0; i < count; i++) + { + var text = parts[i].TrimEnd('\r'); + var carried = lines.Count > 0 ? lines[^1].AnsiEnd : null; + var line = carried != null || text.IndexOf('\u001b') >= 0 + ? ParseAnsi(text, carried) + : Parse(text, lines.Count > 0 ? lines[^1].Level : null); + text = line.Text; + lines.Add(line); + if (text.Length > maxCols) maxCols = text.Length; + } + } + + void TrimHead() + { + if (lines.Count <= MaxLines) return; + var drop = lines.Count - KeepLines; + foreach (var l in lines.Take(drop)) formatted.Remove(l); + lines.RemoveRange(0, drop); + maxCols = lines.Count == 0 ? 0 : lines.Max(l => l.Text.Length); + anchor = anchor is { } a ? (Math.Max(0, a.Line - drop), a.Line - drop < 0 ? 0 : a.Col) : null; + caret = caret is { } c ? (Math.Max(0, c.Line - drop), c.Line - drop < 0 ? 0 : c.Col) : null; + } + + public bool SetText(string text) + { + if (text == shown) return false; + + string? appended = null; + var endsWithNewline = shown.Length == 0 || shown[^1] == '\n'; + if (shown.Length > 0 && text.StartsWith(shown, StringComparison.Ordinal)) appended = text[shown.Length..]; + else if (shown.Length > 0) + { + var key = shown.Length > 400 ? shown[^400..] : shown; + var at = text.LastIndexOf(key, StringComparison.Ordinal); + if (at >= 0) appended = text[(at + key.Length)..]; + } + + if (appended == null) + { + lines.Clear(); + formatted.Clear(); + maxCols = 0; + anchor = caret = null; + AppendLines(text); + } + else + { + var partial = ""; + if (!endsWithNewline && lines.Count > 0) + { + partial = lines[^1].Text; + formatted.Remove(lines[^1]); + lines.RemoveAt(lines.Count - 1); + } + AppendLines(partial + appended); + } + + TrimHead(); + shown = text; + InvalidateMeasure(); + InvalidateVisual(); + return true; + } + + public void UpdateView(Vector newOffset, Size newViewport) + { + var resized = newViewport != viewport; + offset = newOffset; + viewport = newViewport; + if (resized) InvalidateMeasure(); + InvalidateVisual(); + } + + protected override Size MeasureOverride(Size availableSize) + { + var width = pad.Left + pad.Right + maxCols * charWidth; + var height = pad.Top + pad.Bottom + lines.Count * lineHeight; + return new Size(Math.Max(width, viewport.Width), Math.Max(height, viewport.Height)); + } + + public override void Render(DrawingContext context) + { + context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size)); + if (lines.Count == 0) return; + + var first = Math.Max(0, (int)((offset.Y - pad.Top) / lineHeight)); + var last = Math.Min(lines.Count - 1, (int)((offset.Y + viewport.Height - pad.Top) / lineHeight) + 1); + var (selStart, selEnd) = Selection(); + + if (formatted.Count > 600) formatted.Clear(); + for (var i = first; i <= last; i++) + { + var line = lines[i]; + var y = pad.Top + i * lineHeight; + + if (selStart is { } s && selEnd is { } e && i >= s.Line && i <= e.Line) + { + var c0 = i == s.Line ? s.Col : 0; + var c1 = i == e.Line ? e.Col : line.Text.Length + 1; + if (c1 > c0) context.FillRectangle(SelectionBrush, new Rect(pad.Left + c0 * charWidth, y, (c1 - c0) * charWidth, lineHeight)); + } + + if (!formatted.TryGetValue(line, out var fts)) + formatted[line] = fts = line.Segs.Select(sg => Format(sg.Text, sg.Brush)).ToArray(); + + var x = pad.Left; + for (var k = 0; k < line.Segs.Length; k++) + { + var width = line.Segs[k].Text.Length * charWidth; + if (x + width >= offset.X && x <= offset.X + viewport.Width) + context.DrawText(fts[k], new Point(x, y)); + x += width; + } + } + } + + (int Line, int Col) HitTest(Point p) + { + if (lines.Count == 0) return (0, 0); + var line = Math.Clamp((int)Math.Floor((p.Y - pad.Top) / lineHeight), 0, lines.Count - 1); + var col = Math.Clamp((int)Math.Round((p.X - pad.Left) / charWidth), 0, lines[line].Text.Length); + return (line, col); + } + + ((int Line, int Col)? Start, (int Line, int Col)? End) Selection() + { + if (anchor is not { } a || caret is not { } c || a == c) return (null, null); + var forward = a.Line < c.Line || (a.Line == c.Line && a.Col <= c.Col); + return forward ? (a, c) : (c, a); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + Focus(); + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; + var hit = HitTest(e.GetPosition(this)); + if (e.ClickCount == 2 && lines.Count > 0) + { + var text = lines[hit.Line].Text; + int start = hit.Col, end = hit.Col; + while (start > 0 && !char.IsWhiteSpace(text[start - 1])) start--; + while (end < text.Length && !char.IsWhiteSpace(text[end])) end++; + anchor = (hit.Line, start); + caret = (hit.Line, end); + } + else if (e.ClickCount >= 3 && lines.Count > 0) + { + anchor = (hit.Line, 0); + caret = (hit.Line, lines[hit.Line].Text.Length); + } + else + { + anchor = hit; + caret = hit; + dragging = true; + } + e.Pointer.Capture(this); + InvalidateVisual(); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (!dragging) return; + caret = HitTest(e.GetPosition(this)); + InvalidateVisual(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + dragging = false; + e.Pointer.Capture(null); + } + + string SelectedText() + { + var (start, end) = Selection(); + if (start is not { } s || end is not { } e) return ""; + var sb = new StringBuilder(); + for (var i = s.Line; i <= e.Line && i < lines.Count; i++) + { + var text = lines[i].Text; + var c0 = i == s.Line ? Math.Min(s.Col, text.Length) : 0; + var c1 = i == e.Line ? Math.Min(e.Col, text.Length) : text.Length; + sb.Append(text, c0, Math.Max(0, c1 - c0)); + if (i < e.Line) sb.Append('\n'); + } + return sb.ToString(); + } + + protected override async void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!e.KeyModifiers.HasFlag(KeyModifiers.Control)) return; + if (e.Key == Key.A && lines.Count > 0) + { + anchor = (0, 0); + caret = (lines.Count - 1, lines[^1].Text.Length); + InvalidateVisual(); + e.Handled = true; + } + else if (e.Key == Key.C) + { + var text = SelectedText(); + if (text.Length > 0 && TopLevel.GetTopLevel(this)?.Clipboard is { } clipboard) await clipboard.SetTextAsync(text); + e.Handled = true; + } + } +} diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml new file mode 100644 index 0000000..6927571 --- /dev/null +++ b/Views/MainWindow.axaml @@ -0,0 +1,627 @@ + + + + + + + + + + + + + + + + + + + +