From 21076761b989b72b4fed7c4e9fabb78dc9c33685 Mon Sep 17 00:00:00 2001 From: ioan Date: Sat, 18 Jul 2026 19:52:48 +0100 Subject: [PATCH 1/3] feat(cpu-bars): add CPU Bars plugin A bar widget drawing one vertical bar per logical core, filling bottom-up from noctalia.systemStats().cpu.cores, so an unbalanced load or a single pegged thread stays visible where an averaged percentage would hide it. - Bars are composed from ui.column/ui.box; there is no meter primitive. Height is strictly proportional with no minimum, so an idle core shows an empty track. - Colours are theme role tokens, so they follow the active scheme; a core at or above the threshold (default 90%) switches to the warning role. - Rotates for a vertical bar: cores stack downward, each bar fills from the left. - Updates once a second aligned to the top of the second. The host adds a fixed per-widget phase to timer restarts and the script runs a moment after the timer fires, so the interval is corrected each tick from an observed round trip via noctalia.nowMs(), biased a few ms past the boundary to stay on the correct side of the second. Requires plugin API 5 for noctalia.systemStats() and noctalia.nowMs(). --- cpu-bars/README.md | 58 +++++++++++++ cpu-bars/cpu_bars.luau | 148 ++++++++++++++++++++++++++++++++++ cpu-bars/plugin.toml | 79 ++++++++++++++++++ cpu-bars/thumbnail.webp | Bin 0 -> 32628 bytes cpu-bars/translations/en.json | 39 +++++++++ 5 files changed, 324 insertions(+) create mode 100644 cpu-bars/README.md create mode 100644 cpu-bars/cpu_bars.luau create mode 100644 cpu-bars/plugin.toml create mode 100644 cpu-bars/thumbnail.webp create mode 100644 cpu-bars/translations/en.json diff --git a/cpu-bars/README.md b/cpu-bars/README.md new file mode 100644 index 0000000..884e59b --- /dev/null +++ b/cpu-bars/README.md @@ -0,0 +1,58 @@ +# CPU Bars + +Shows the load on every logical CPU core at a glance, as a row of thin vertical bars +that fill from the bottom. Each bar is one core, so you can see an unbalanced load or a +single pegged thread that an averaged CPU percentage would hide. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `ioandev/cpu-bars` | +| Entries | Bar widget: `cpu-bars` | + +## Requirements + +Noctalia with plugin API 5 or newer, for `noctalia.systemStats()`. Reads per-core usage +from `/proc/stat`, so it is Linux-only. + +## Usage + +Add the **CPU Bars** widget to a bar section in Settings → Bar. One bar is drawn per +logical core, in `/proc/stat` order — `cpu0` is leftmost. Bar height is that core's share +of busy time over the last second, and a core at or above the warning threshold turns red. + +Hover the widget for a tooltip listing total CPU usage and a per-core breakdown. + +The widget updates once a second, aligned to the top of the second. On a vertical bar it +rotates automatically: cores stack downward and each bar fills from the left. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `bar_width` | `int` | `3` | Thickness of each core's bar, in pixels. | +| `bar_gap` | `int` | `2` | Space between bars, in pixels. | +| `bar_height` | `int` | `16` | Length of each bar in pixels — height on a horizontal bar, width on a vertical one. | +| `warn_threshold` | `int` | `90` | A core at or above this percentage is drawn in the warning colour. | +| `normal_color` | `color` | `primary` | Colour of a core below the threshold. Accepts a theme role or a hex value. | +| `warn_color` | `color` | `error` | Colour of a core at or above the threshold. | +| `track_color` | `color` | `on_surface` | Colour of the unfilled part of each bar, drawn at 15% opacity. | +| `show_glyph` | `bool` | `true` | Show a CPU icon to the left of the bars. | + +Colours accept either a theme role (`primary`, `error`, `tertiary`, …) or a hex literal +such as `#57ff57`. Theme roles follow your colour scheme, including light/dark switches. + +## Notes + +On a machine with many cores the widget is as wide as `cores × (bar_width + bar_gap)` — +about 120px for 24 cores at the defaults. Reduce `bar_width` and `bar_gap` to fit a +narrower bar. + +Bar height is strictly proportional to usage, with no minimum: an idle core shows an empty +track. Since each pixel is `100 / bar_height` percent, usage below half a pixel — about 3% +at the default 16px — rounds away to nothing. + +Per-core sampling is opt-in host-side: it runs only while a plugin is asking for it, and +costs one extra `/proc/stat` read per second. The plugin makes no network calls, spawns no +processes, and writes no files. diff --git a/cpu-bars/cpu_bars.luau b/cpu-bars/cpu_bars.luau new file mode 100644 index 0000000..e12a4f1 --- /dev/null +++ b/cpu-bars/cpu_bars.luau @@ -0,0 +1,148 @@ +--!nonstrict +-- CPU Bars: one vertical bar per logical core, filling bottom-up, from +-- noctalia.systemStats().cpu.cores (plugin API 5). + +-- Never request a tick shorter than this while converging on the second boundary. +local MIN_TICK_MS: number = 500 +-- Aim a few ms past the boundary rather than exactly at it. Tick-to-tick scheduling jitter +-- is a handful of ms, so aiming at 0 lands half the updates just *before* the second and +-- reads the previous second's sample; this bias keeps every update on the correct side. +local TARGET_OFFSET_MS: number = 6 + +-- Tick alignment state. See scheduleNextTick(). +local lastOffset: number? = nil +local lastRequest: number? = nil +local lag: number = 0 + +local function cfg(key: string): any + return noctalia.getConfig(key) +end + +-- Aim update() at the top of each wall-clock second. +-- +-- Two things push us off it, and neither is directly observable: the host adds a fixed +-- per-widget phase to every timer restart (so plugins don't all wake at once), and the +-- script runs a moment after the timer fires. So instead of guessing, measure the round +-- trip -- how far the last requested interval actually moved us -- and subtract it. +-- Converges within a few ticks and re-converges after a missed update. +local function scheduleNextTick() + local offset = noctalia.nowMs() % 1000 + if lastOffset ~= nil and lastRequest ~= nil then + lag = ((offset - lastOffset) - lastRequest) % 1000 + end + + local request = (TARGET_OFFSET_MS - offset - lag) % 1000 + if request < MIN_TICK_MS then + request += 1000 + end + + lastOffset = offset + lastRequest = request + noctalia.setUpdateInterval(request) +end + +local function buildTooltip(total: number, cores: { number }): { { key: string, value: string } } + local rows = { { key = noctalia.tr("tooltip.total"), value = string.format("%.0f%%", total) } } + for i, pct in ipairs(cores) do + -- /proc/stat is 0-indexed, so core N is at Lua index N+1. + table.insert(rows, { key = string.format("cpu%d", i - 1), value = string.format("%.0f%%", pct) }) + end + return rows +end + +function update() + local stats = noctalia.systemStats() + if stats == nil then + barWidget.setVisible(false) + scheduleNextTick() + return + end + + local cores: { number } = stats.cpu.cores or {} + -- The first sample after enabling has no previous reading to diff against, so cores is + -- empty for one tick. Keep the widget hidden rather than flashing an empty row. + if #cores == 0 then + barWidget.setVisible(false) + scheduleNextTick() + return + end + barWidget.setVisible(true) + + local vertical: boolean = barWidget.isVertical() + local width: number = cfg("bar_width") + local length: number = cfg("bar_height") + local gap: number = cfg("bar_gap") + local threshold: number = cfg("warn_threshold") + local normalColor: string = cfg("normal_color") + local warnColor: string = cfg("warn_color") + local trackColor: string = cfg("track_color") + + local children = {} + if cfg("show_glyph") then + table.insert(children, ui.glyph({ name = "cpu", size = 14, color = "on_surface_variant" })) + end + + for i, pct in ipairs(cores) do + local hot: boolean = pct >= threshold + -- Height is strictly proportional, with no minimum: an idle core shows an empty + -- track, not a sliver. Anything under half a pixel rounds away. + local filled: number = math.floor((length * pct / 100) + 0.5) + local lit: boolean = filled > 0 + local fill: string = hot and warnColor or normalColor + -- The fill colour is part of the key: the host reconciler is retained-mode, and + -- keying by state guarantees the box is rebuilt when a core crosses the threshold. + local key: string = `core{i}-{hot and "hot" or "ok"}` + + if vertical then + -- On a vertical bar the widget grows downward, so each core is a horizontal + -- bar filling from the left. + table.insert( + children, + ui.row({ + key = key, + width = length, + height = width, + radius = 1, + fill = `{trackColor}/0.15`, + justify = "start", + }, { + -- Kept in the tree (hidden) rather than dropped, so the retained-mode + -- reconciler diffs a stable child list. Height never reaches 0. + ui.box({ + width = math.max(1, filled), + height = width, + radius = 1, + fill = fill, + visible = lit, + }), + }) + ) + else + table.insert( + children, + ui.column({ + key = key, + width = width, + height = length, + radius = 1, + fill = `{trackColor}/0.15`, + justify = "end", + }, { + ui.box({ + width = width, + height = math.max(1, filled), + radius = 1, + fill = fill, + visible = lit, + }), + }) + ) + end + end + + local container = vertical and ui.column or ui.row + barWidget.render(container({ gap = gap, align = "center" }, children)) + barWidget.setTooltip(buildTooltip(stats.cpu.total, cores)) + + scheduleNextTick() +end diff --git a/cpu-bars/plugin.toml b/cpu-bars/plugin.toml new file mode 100644 index 0000000..467f46c --- /dev/null +++ b/cpu-bars/plugin.toml @@ -0,0 +1,79 @@ +id = "ioandev/cpu-bars" +name = "CPU Bars" +version = "1.0.0" +plugin_api = 5 +author = "ioandev" +license = "MIT" +dependencies = [] +icon = "cpu" +description = "A bar widget showing per-core CPU usage as vertical bars, turning red when a core saturates." +deprecated = false +tags = ["system", "bar", "hardware", "indicator"] + +[[widget]] +id = "cpu-bars" +entry = "cpu_bars.luau" + + [[widget.setting]] + key = "bar_width" + type = "int" + label_key = "settings.bar_width.label" + description_key = "settings.bar_width.description" + default = 3 + min = 1 + max = 12 + + [[widget.setting]] + key = "bar_gap" + type = "int" + label_key = "settings.bar_gap.label" + description_key = "settings.bar_gap.description" + default = 2 + min = 0 + max = 8 + + [[widget.setting]] + key = "bar_height" + type = "int" + label_key = "settings.bar_height.label" + description_key = "settings.bar_height.description" + default = 16 + min = 6 + max = 40 + + [[widget.setting]] + key = "warn_threshold" + type = "int" + label_key = "settings.warn_threshold.label" + description_key = "settings.warn_threshold.description" + default = 90 + min = 50 + max = 100 + + [[widget.setting]] + key = "normal_color" + type = "color" + label_key = "settings.normal_color.label" + description_key = "settings.normal_color.description" + default = "primary" + + [[widget.setting]] + key = "warn_color" + type = "color" + label_key = "settings.warn_color.label" + description_key = "settings.warn_color.description" + default = "error" + + [[widget.setting]] + key = "track_color" + type = "color" + label_key = "settings.track_color.label" + description_key = "settings.track_color.description" + default = "on_surface" + + [[widget.setting]] + key = "show_glyph" + type = "bool" + label_key = "settings.show_glyph.label" + description_key = "settings.show_glyph.description" + default = true diff --git a/cpu-bars/thumbnail.webp b/cpu-bars/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..aad47f98939ba7215c34ff13afdf51d838144521 GIT binary patch literal 32628 zcmaHR1#lg`vgR=}GsMj7n3cLJS0~KB7}M(CnC2gB`ftqt5B79(a{DWz{10|fR~7q<&HrK=tN*~J|AEb% zT>g=d{VT(7Z|DBcSpU#J)*)IrXsG?Y!v3v<09Sx2KnfuGkNtn||B_=N0KokS06--E zPn&550MPaw0Knb&pEino001Qn0BD{4pSJ(;iIa(o$-f>4`Im!RS^@xn$^ZZ)9RL7h z4gi4H`&Zvz_W#h0`cZNv<`~d*v z8L%8Mx;_X(a6vfmVBuhL008yp&lB#dlz#2fDeBD1&wJNF;A2@zf4+0BfuCftp~4DI zUra;h>jdC&`nPQ&Ows5(p)r1XZo(d@;^k~VhU&xjP3XtY(N@HP&>+9utK$m>kZE2p z4S18g$_lCjLA=)hnL(hH-8bT!-huazPY&S42TULFxzO9}wNX3qNx%=#BB%%iv6uH8 z_yo)c9m`54o4N`@CVLGtgb{B9KugVJ1%xc({bK!khro0zteTe>MS6-gZY5 zYJt12mMJZW`b#y~uPhBHo-)NZPc5bCOUR?eK&Benmz`;J2Kbc?u87C3!ui-k$j_P5 zZei60873P0?+MD^*O()~Qh28bhwt8t);bm4cKOf=LT9Yvg58r;h9C9OGp8wJkKA1` zu0{oB#!!BVQ+l$FatjLIgS+YY^{?OG)qYoT(f*X0!8dU*}9y&(2 zO-jj{))ia!f<67{(0k^`_iU|%A+2W_=9tp}i;Fv$T({#p5zT2~tA5eTIMd#Zq4)fY zc?56sS>*YJCCYA?PfxqAnfI`Ifd%za?&s;#gi?#?uNuInYZM*McU0jj(RzJ61Zbv! zO(52t-)xhk8EffNe|+eu*~m*G4#P+be{UQEo9LNeBc10w|LgMW{;b5MF_~y-*#qNv zeH&6_g&X1>M9=5&Yl3es{?0~_Q}9Ru1%6E9Cs3Gz-Bixf{YY7NZn&pN0GMVatYPJY zxlrs!Q6yd-4YeX<&~8DGJZZh>Z>ak|lk+8KP?CPc2bzt<9{gRboSl8t zUMbTxZykPiypJO#YM&)skkfN5Y=Vnfi6zR@yM|y{zzm@(+7(sd{ZM{m6~mrycaTbR zo(6R@qhO_zI^_L1`NJG-R#!FCBLo-Xnu5sHuIrAcYSY1P*SaGw954$(z!HhRu9jZ3*4iN+hRR>8$7T;V6_(@EE~P@XXTDXC8i z@dkCaJ|Swi%n)%v`v;)@nBBm|uDcc_m$ugp9ni2GFl8h7fZO+D=sqSi1gApev(S6E zKfgV)RN7J@R;;`kQ;CAaRNYZEOGQLsrmHIhF);Y(j{jh9@B6uO;I~|7r;zBx?M4@S zGIh?z2m?I0gSj)LUil8u�Rx|Ka?l|B!SNz4x7_`PBNv=S%%17P&Tx3320}6>DOp z$*Bx+`&(hLfQo68Eg=n(SvB-s{J-?6dKfIr(q#?B>dac#itR695+gq12%x1gy*Kx( zDm2kc&XReg%>1ghK1Gj<%$bse+Gu_CBp&=$MteL3 z*V(zCjt?ESrOk=dP-v1>%9ykrLsjLB0JkO1Ep0Ec4%>LseCKKJjKM9oNcfNcWKpg# zW|)v+4U2H@^*>-)oZyLO&?-gwzP)@$rC$X#84CQmddZDXw8^L#OG0myPr?92j1X&U z8`Gpw`6K3~3Pt)`xj`@=0Hq1-NMLYa{=-Ouw0x+i-U>Jdg--OfA=Pp4syV8NMav}n zTiYF7jBu|Ovm&&lFT}zt7laO@?`Nb@Mi|?cGy=)<#1Q=^tp(AR=5LH6d}zy%EH_XTW=eH#B_6 z{6D54ZKS)7-g{rzLX0x7IDh24Uw*z`vWdJGTbiQo({RDcg8f{7f%n@Y99NrAZw_E8 zt{by&&+rkRb{+e`*hfh4D4`K*RwR5!MkWTK0A;8e5~n+r#{$x-Ag(^~5A)vVPs`3u)T+-a>h#~l# zb4@F}b*{rVxV{h)VfmAdK9v-$v9pD`)nxM0Z-BHvBXRQI>*2qGGCymF0k6}FeR>uN z4$z2xr5XN|okLnxs%U|FNm-7e4d;zo$vbQ?Y*gD zT7k=TyHU;C6K6^lj+jXo8&E_J-{DPwkMW5)O4Mt#>X3oyrXR zc1?Oc0qza;?=|-?2h8*DXeA0ZZ*t|yO-QWs(lyuu4;$>q{J3;+P2eGht_zx34#zkA zWA-vB`^0b;>4?=F;5{zSlx|X59PS!dmO160Xo;de)IRzpThN^TC=jw4@vQb&)35kI z+Rpu-QOA8tZBAc)eg!FFEdU025joLehaIb!gr+nBtpowVrB&EFtVk;98DHRi@HZp; zmv(&eUv& z`Rfs+mym^terR?}`lZ*YdC+ zqd{N4%SRARE}|7@>K%@lEF6fY?`g_`mI`LzH3s`nJH9V?l!Pjj=TThR!)2p*Rc~8j zBDa9K`zkXugUNSE9w#C~!>V`I+VXB8fWhM}2#pN-o3pPeCGeI?6}r|XtQ170G1qCk zt!G~@7-p>II&JQUjxSgU>2o2995_1Zlh9$Hl2G}I7TD2G7pt}JC*h==5)2!Z=%HYi^p>|31ED>BfG zm%H|X6f%*)PGUdl0b342N&)LXo({t$pudE%d;cBV;C zmf!r|8=TwEdi+ifLfbW|08M+qVIPF18?hGjTt4LUE2A+Oa zaX+?ykc|@3`uw2e&33V&p2$eWt`_X#4ir&pwrAnVSGCiJ!Z^Di0brDgwo^61q5aSf zZ(1vgJCHRyrArlY+?=S+*z*j7faj8f?&dB@&-(quWr({|^t8T>3)=6{M!&wHiSkB# zh38PBd=7+Ls#B3%=!WHu_C^vws|E^i(9z=m7*S^(*x2cc`0^dBhBZDaNZ#Z8DF{U% z9_ojx8FvjqCBL3TMXV3LV~~I{en#MieS_b-$k2B%$T%;Fw--o4PRTD&1O}MmUk%Ui z^P5DKLg4N*&A`AIU;6JL{FWU0Q2kk{@kc2sHWP!(6>c#_LQs5hG#62z;snHl3b#9~ z9O6n?6FW06$4Dd_Eb(|eyyiR5_>niRsdnYJ2e&O#T?TJN4vbzDu5Yw<*GLOmKF5`O zCn5~-!@+u4fg8aC=4r;T1iv)tt-ZI2Ge4us<*I9m>P3sG} zLbwaFL9j+n#%;!kp6SOPni|VNj?baMSs-VW8p-um)eRbGjnIZ{(wJ7$ZlPdOs zo>FbdUPgS9+Xd7us{)^VweQh+h0wv>K- zM*P$agSmu^D$=QrD=QY2Y2IZ{iABi7cPr|-?rEK7dRA8T^r3<#%*tL?<@-a$+J`*a zbD8~+2T(FexJF1Vp#!Cp^643f=O9i~eA>=e_={6izi1DkHofa9EV%nZscdLXkC9G! zLiTCO4$?~NAk+y{P3O6jQ<2xK$<*_21MdUHrVZMz?Xl6C`R8otd809DCWE(6I1*LX z$O<&o=iq}eOt)a8OICOqaug-yK&_5GPCMpJid}C06?Bqbst>6tAQ_WwdiI7CQ9oIB zmZ4yNF3d^|Hmp1}^R28cnZAHa;qhuDuPE--JsHjPP8W;Q;bzTiVPCZgB&!3{G%$95^ zVROq$ZvJ$5;Cgb`3(eL13NM6mXdXNu`^<$qBwlKo{Rpl0r!F2qbf$mZ@j@l046-nj zNjCL5%d*9(BGQIzAtwb&+x9c+dVB-03Xk2v43p{v{5bpF zccFl4_Hl@&+Ei7fHawlo=BwjGqJQ;*1K<^iq21At3h|va3R*%m5rjtRZ=mF%H#hI= zBexo)CdA2GFQh#a*%zZObDvk@P|?79pKP3q5pgsjPSQ=m8yB7>&s-iv)!4fIB;i?l zHO2VjWrf)_JKzx;jq@sg!c{>y{zFU;=~u7Yi)RDD`fWy)vr)Pl%c%4jzLY*Z~qd zWO=ZvUgU&FjAKzG$%{W4S5|9}o)Rr;+%(3VOlvQXFb1Lq!=2qNkg;wnpj5<+6I#DxSV@SHr}Qd8=kBrAu@#Hu*BcvenP# zDyM{fCyL!=i`sgoN%972(S;5!QgKt7X~XZ4E9Xp6nwFjh`FGZJ#Yp%J-nIgW$3d>qK`nF!*{~%`GaM z*p5#phxY57+B+AUp1c(jSrOGFGaQCV0-%h*g}VVqMf;oX9t4zbe`2{+8!@f@`za2w z7YNp#^+ytp2fPPp!6a(e@j!Fg}s)vWG{>&f=4B10e7s(p)*ms4gW__~vDXUNDX zmYE;k4|nMuX3`gWvhsMDZ{yC~IDHW~UEALfh`sR=bR-zY8E z%hdS^CA?8C_Op}Dw5~(+Vn$t}6)4S$N+#(2t>vpQmg8E!+&;p=(j!ljk__#c;8h82 zhiEtK?N^YnuZLdSX6VeTd)6KNwc-baL_`;1os@BgXG^3D`BMRpE7G2cmTj#w8l_Ek zuA)mKil(a(G;9w4yS~cx=dScjNxICUFk}Z-_iFG|5HnkAbv&l*Y|!HCY=oNq?*-$c z5$#H=-p4+}@odQZ`83Ng=v<>rks3)&4f?93XS>V6{HW}#*~e;^RF9?z!%?0T{|Ebp&@N}1R^rKIS+Z0j!+~l>m8G-Ym zB%%XLs(B7G82>L}udis7t1mdKt>_-*Y8d>BD0dsrUQ7r`EKHJQHAyAIIWMG zn}<_s+>KL+#s?SkXyl-o54KFimynPAiyw<}APYgNOjI#uM8P0=;q2^w^Owv_&xb1)ylF_|%m~dttGS0@MG1XR*MOm4 z4v*#|F|B@R*-hW$8!fp|)cgvKTD>z0I?`uBshYgJ$b=qEHV^W|UDZM5M}ks6nQ8l+ zr+1U3^OX@+v)_-FT1ARK3Ze1CJ$zqkHgJ|BA&n71FR zKvfc~!j5eq`^O5N8ERK<;d`U7kJN{<;L25z3dJLaO5pJBxnPOZy6)7riN3^W5%Dd> zMGmM+!5)2|0=ys&&Gj4FW5sZ{+_1@KzM>{-PZj%`tefcVhpOZ6!Utspfyc!^a}KK| z*%iLb_S*6s=PBXMKzNVv;1>xFMk!E&lghblNvfVPS_nA-t_TPSIf60(W8Ov=Z$;%M zg<~JNv+|Iw>e=>Vr`QAg$4a>2T8a(mE2m|fuPZ%_$VvAv{VCTKP)$1zrhi@_1y8ylkLa-%`@C(^& zGAe{N0r%C5qQ&jXGRvTP=_0A&QeYE>dpH@9f7E>IPks*#&LH$)|?vN&S^-KvJNFwAzxf2*+tc@?hKVj_W5*9Unh#*hkWIqh0NQ* z-85Rx-N!yY;M+Xuz45g8nO%lD$wqJqWvwZu96)qK=6tM>fn^OI z3JKY)5Wa-6?c$SOGaRX?6smK2(b%XgEDN@Tla~~JSvoHVPP0a=qzZZl@NRJIi4evBgCT`~z@1?5SulUSAMZ_EGJ^a~VHPRmZbsA! zvcI)LI?pGF(2-?saz|J_y#kwyyobx#v|%JkR?R!iu=kGzRW;+mv)UEVe}hhXC{xDL zmL*1qcD@E$T*uvmT*~LSZU7rwLltf)xUKC;x$PLki^o9*J4y>y`Xs z`>|(?7@#|yYBQ9t1yhdP(V5(*C)d~(bx>Gug2T!DOLGt2-L2b){TtYtS?hzRKk3Sj z)uZ22XjH1tp-|{_TPoEzq@QUP_8j)X_a&rXM+=8LctGEtPPRp-FJyTaM8&Go2^6r#+erF+i!w?$!O@Q`CwkkAsg5>59#hUqb%%)x(iuGWtJKICS zpy-Tdt4fUxoxGSgieLd5ws(f$(8B==mNBMXK6kpwH`m1)e0s+Jcj$ebXj3pLzCM8o z&~IsI8aKvp_UncKVyR!E_dy!Gj@d|CA`M-d za+vUyXhF{1q9#0-HKyC*0?dTAmHeO!_O1oU>#sqXn0WeVZ`L|~1Rm?w_YMV(@#Xna zn=!~zr2XjD*!pYT&r!!g)q~g?xQo1g1UQ-#ci9aHrz`}N#swHCH(mJDq`{-gp5poX=An-#qwh& zPZ0#eP&tDA>TQR)XU}ofD2MV9iF;H(A_3#aWr`Qr3D<1TH(6fsdF_{E-))W}sd=YQ5i} z{_cD8vT0;zq~vIs6XIgz%4_2YkAE)YtTU*UQQ;A~^fQT3i^a>GaF+o4eS~nm-j_+fGZqp}E4V;w+-tF9 zD&i8a{esv;Cu~Ne;?E<1r>$^VZajj>O?|dXh2j{1xEJrnV`60at~jqwnd;$YaNCb6CF%3FdUt^ ze|UlKZOqqIhc&^SIMdtdnq&}KLp~d3NQP=ZSgdNnY*3;$GIWeL&b3fFH<@5F^;L|e z2(rGL2paMH&PAqp8O%q;eT-z;WQp%YypruJ&9pT?XD&;A^Pf16L zA-}RzjkLWnlK~J{rk(N`Je0n~uQt$V#~+&E=Xeq*R>#Kopgwcl2#a=qe7^G)Te}~$ zH+WR!72g_BZgEOkuQOP8yQvJOK8c&XQNp4F1kpeDKvZ8;-^gF&_4bx5O1QV@NJ$`0 zY<*On<>{VY6I;5!tNRvNmBTZ0!|-E3O6jLIUS}gmH8Q~Eg#-j*>hvh`;A?lH{{a_6 z7zg3|oLTMwFm|{5x?ODq3gYFah@-fsP4pS}z7cQzkzfU?TKEZ^{d^CAJD^%X=BoY) zWvzd{E5fbOiM+J<-N0>{v3(mRhy2NveR=!!YE$mg4%BgZ_+dUJnP!gtB(t*-4DX3 z2(ZT1RroLo>ZpV-%$mjBI=>BrWCIZFhOy?m&97)ry)t@3ls3-3%F8Ge0ym*{UW?w! zZQNa*I}+2{EQ5_pp(w`?`CxgJrQmG`7GU?YEr->pgufeaKkXLZW#pi1M{l6&Z6qS| zW+yK784H7!?XXd^HG;3xEP&oENlKwTvoficDxXd+r54sL66a zl!r@K+bYVVVt(o$jK#*O=i%%J%ZYh58&q~Kb>Pe}9-MP_)lOm;LB!dz%Rgiah7u`s z(eUv+G^d7U&!0l~oTL${I3`PT3pT@m%qem{RCw1snjM(o;Vjw>!{tcI3=#0OT$+sw z3fIB%BD~)sMQxTxhkut^O-udMpzn9TGkY8l&ZD-rGrv z`N$Au(W6v9?ZfpYAM&?qSot=3qyxi5QZD@~x;cBf#vC7Mi-`%mW<{Gx(*f#Nt=6!B5VK9TEP{X+Go1IF?=kHG2BTN8*yt`4o=fEKnB5 z`6CXxHGIJs3>d^|b4_$VC`W~_(8}R+QV;46Kho#Y%5iqFIQLjQUhT_5ad;5Q{J6+V z__>VxemKZZo=Oa6)c|Z27G;+SmsmY2w9`Nc}MFzBv%4EOPC#`crsvrjIbHnO979 zZXPm*fgh*rc0nnO)2+%}h%TYF_Xc5VnxQCI0|j8H=8K(>&rIA%l;@H6lFHqSbG)9+ zPvQkh)$1jScCYLd^*K!-4zOG$QFAjA>o@P`ComHko3UNT1NbSo-36qX@8 zo<4~3Hfd{~Xd$?)07krE^?MR+h?QPSSm@vhZc5}rIMcwG5q6r70+GT=bk;4U7D$*O zb)e6l@-B-6o5sNEd!2bfr6$UvFnJWm)$6679b}JmrcgRd@6vjeB=|z6#=xLopF-rR*N~wIe ze@QXV)q-K!>+;9x_PV?55oNnR9(=o-rt^$>tsf9?T*riBy-;tos1XB1_*!L^L98wl zbacmg1ji|hv@Q!x2uSXNEGe+D@ngp9#g5mFlb}DU4e!p$wDR^SgVqlm7om&EseLb@ z>6%v$X-I`rrtoDOHrb}GYzXXbvhK21z6)qIBAIoJ$`OAlz-ty^`t9%6*{oVfla$=Q zG*L6*erfN(va1|Y;tpM>NYvWckU@ilxX_er}mA;oH? zUg(!i2RN*@WykyVmk@gmpjsksrFXsHADI*!76QX*Sf7jCb2mOhiAV9Ko?z&#uv?ib z`AM5$xH_ESJH{|uV9f9aSJMWO{)pyg9+S^lY#3Zhg~>b>fpWOqkN#z^n&5&Pth>Iy z=P>_G&{QPL6s884VOe+6EZ*+E5YJ)CZDh$?y}7PNiIEW)U#k5LVY`&scsrx0*&afj z!6IfKTxZF^rC)A$vNu-ilOx@foH1k48Ej;ViwuY%k=hK0(%Cb_!(TDycY6I~-G!^H zC*2ou{{(7R3p0tqH^4%rg}*?A@$p1=gODf{cpqIaVaf(lf~>HHphR75#pKkgW&O%? z+RnbRX|w{@BcrCwH^2Ux)}p4#R+FDXwVgHv^StjVF}r%JgF?)uo;R0eATCpm(e{!p zke6K5$9g2{w^r53<8$1kI~-aPY&MHgo5cK4K8DWof^Lp)sgpajF-B90C;j`y%(aR@ zxK{B(_`h7CEkv{up*4X431v_jVcsG!Nzun=+AB!)>=sdpd$2|>f{3A{^vfsO%X^2H zCr&h-bD^{0qLHCRJOJ(S&y>Ka%@U#V@&V69-T0qOq1pwl96j$6-QHd5l?Os+DOHun z;go!`GP7tc`3TO;v8A~pY>Bbz$nFp~&2gW17PrJKc&y2oz@@u_VD)2d6rk zx1WYJATNRPB84Tl@S~L5$8IfjQ$B5i;l(_1I{o{A# zr2*&GklS+@FyGU%BOpdcwZj~C*>N?50fyihmGrGG_nC1o1HwKV=a@Lw&xnZ@flNp^ z4qhz$k-7)RSh+2CCb}39YMt*)Il2KIA&X;LZTA(63ZNBKR~_m$WtUL`3jBZ_~S| z_HN4I>pcubjGB@AwY4Ss>km>#Y!NbX_8DTv!rlr**q1n)LmV}ZbqyQtoW7{*fmK6S zt6o^a@FK>{4)3p@hiBRCr*Z$9W+?G1Nikts_oZm>RQ?V7^a0c{yIrQ))9I$RC7j2p zU3{XmTfUGxF)4&8&QJj(Q|}eIiDe7o+&LD+)>e-{2olDq__o)#{YtHZztpVHqk9n! zpJ6_V`Gn*Urt5!jdulD}Pkr=n2I_(#3NWIwtwX0Xtbl7ip>WyRJu}Cxsy*QV(Sn!S z<6p)i>Rrl(rEvOk=68zC4#>Xul$~urv!9N<#z3-`=x0XNOezSjPPSk4#`5n%2uUjo z48|%fEt5oLnK(f#`$Th2Bw9HqkwQ@2He;j_j3=Y^jI7RC-1H@U#2`n5R(BWzm`p~R zB%C#p7Ck?_53=M(mRP~LS+w+oOM)cqQ=VuAFtsJQgZ_yZ=Gr&0hX%Gnj_*5V9%zmx z@a z1fI^m(d%?T$r2QjLG>|Q^RVsSxQ<#k6Kh9cDORQ2>fn}Al+Jh4lh*Is%tJoFZC*oC z#)e5@8|Z-31;P`xcE?Bf`gN9L-fHB7qYn*J9iJ%hYFYSDK9?4Y_9LT58=UXfgBu|5 zD_BIWA(gXdus#2v1?G=iW%nsPfM$XgaofNo8(P`GV7O7FupjKblm%x*aajLl9%m?3 zl0M}~;z*z3F5O;EVd49c&$KUl^CnPw;S^I|&)Vm)3s*)av)o=SM>q~2nMDbYebG|s zu#0Do*W1yOuKz*o%etUJTwAdem3g5rA(NU4yuk)~aGYLWE{%$5+wt?s)cz7~k=82_ zM^jM{7(f43_t!*e4Sa1G$tA> z4~QUp;YGFR-^f1ghi*hX6BdR2nIH_?CoS}a4YxC$_<;7RvkRX$A#fy<^Fl{#pB`4& zLE#)~A&al>PaMY58L&{^R~@e_l>Fgx!zVb!F<-VI;$h7}f3Hehuc35JAyvzLF72Pt z0016O#w8N!Qh||AM24CkCbrvXS?onjd`?#Zu9GfpP^FEUcW-WcTT}wwN?P6; z8}N;TIeNUZbqA`!Z4pQsmECvWrd2O*J6buOltLB91BI-FAx9DEH=JMhC0h}Q`4tp# zj7bTv0#t>ojK2}rz*PxsosK@GL@M(zY$#>UJ%F2E0aAaS1FrGeX4+)OpXq*7W@HV> zYUjyY@+M|+PxC*!xBCFsTDV|QB;Gtlr0geFs0PpWQ@(G!NF}ClQ;1{h!ae$KxUG~s zc~$l^e>;;qndxUu24&Ed7rerc=p2gw-o*~Srw$6I0lTsU{WdhQS^hMtV(>SgA5Nc2 zf&*()6zQ5&Qs3`{CtgjoyhD|LsGN5(6&e)xy-ersU@9@S++W{*%#wU+u6Z`~3^;4C zmc65+h_pY@8SrM@HW~>ToSzsQ#<&oBRB)AKB9*c_il$pWJ!ID{y;qe$I1@;IU z{_qoD%x6n2i?lwx&u65!PlU2N%D{kn)cs?+AB{`5Mowfo@aNKQ2L;KMfH%`_5avJ& zqdObM{XU(Fer5HQHLgVfH@xhOyM8T(p(XHlH=Bph@78=I5tig#^HRqcgy9i4qCw^^ zr9PTHMoO7X9v;fM4Nkua7~Q6(2I2N1VU1#^rZhN=D2B^qrRR`dEU#moqIYugT)O4Z zAUVO+NW(>fm4P=L%_@%LYv@#i+1FnThrd+NSB#m?Ipwx5GChGYs6;_P>xj~i5bmBu z%iD>HVu;;niuE$1pnsxo%zfk*_J`%o1#;qQvtcgD zm5){xjBc1^L@C&5&@&xhzn!&ZDuOgpp38<>ZEwT@=W!w2hg3Oz;c1wVGtwkh=I&O%zLu)UWV2rqxeb1{Kp^G%Zr!8mtGV zd#>E4U+&COBHif3n3PuQmWTKSUurJOV`7~`+^}hcG%m%v#qRb39KF4v%l0F{#e&*j z60=gBsn?V%>8gS!`@A6$YOs_O?oVmmu@sJLq8GDhN*pV%CfVJJETMIO!kp0{Vmb9U zpmJ_h=7bcuW?zbk?zZNq-_~7 zx&zngBr$1PJ9D3yZ2h|>NkJc$w&nJq?3Am* zAGcs)(-W^l@Nia(JXmeSJP@&z&8JbEL!(e%DMmlNRd)4a!Q?Y4uvt!iigu@d6Ly2C zYES<-A-&!DSCKz2vCB%j5^WG%nOgZv`h+*EVvNk#HWvpx0(^$FrO;K7Ln{Zy+NDK<4>Q(vxQa+uIe|w&@%#+X#9Zsw5EWMu2EhO-asReW9p@Co1y~3;3GjJX}m6l zO?%O=^EH$WaV0P!LM4eCkcZ4Fn3hw-GNh1DPq{Rwfc-$>Ho3vXeNVN$ept0(t1-h* zXRv)WpP72^ep2mOKJ2Pj*?E0m@k&5aw>rhSohy!1m#MNQ6`3l}sVyGY1pT~MTue;; z82zzo3BaxQldcIiTuOpCNEF~6|9_H^Cr>=t<1ZelM~&5?r0GEoMZXxHHeVT zOwJ@Ua)#%;8e}^IdG+Y2K z2Wo-A5oJ)H94=?OUL~{*U3CfZMk;@xX6I0j-fg(A8eVjNe33Hfa;89A14>QIYeBYqNz`uD+mF$r=4Z1ddOc(yy zQxk3Nz{iLVABeh?-ve(71SLm#B1QTH)48zbd5X)BGz!nBUsU8A^p|v3aVCP#hG&E^DBCcTSQwoNl@pO z;aoR|M4est2#Tua7XP6mHgYJYsTt?rT{)aIFpIcQgs$o848|qyR4=r#>6AFJV3*7m zoxIg%*^c%Ex&*ain6dH{{3kq56bnmoN9QNqz%m`rV(sj{buk(q;xATJMzM`j&2?OE zq!C$VgxUaEF+BgHr}%VGbyIsSLvw`zX(3Qrc+fad3|}P%YT=YgiP;V5BtMlB}ncRHrDhLeMfrV6%E7OOM#6*V!+ks~JUSG;8fbrsr$MoNSljHF7VTD;rvfoy9R)*n}N1>Sxpq^v5elJIb?KNG8f;{6xSpCac*Am=Kf@oMWBoC`3- z*t9Ch3zU-3QD!1qa>?b=9c=#eU9J?P(*11f7a=RDp~P@7W@NKB4>ccsMr4O~&c~Yo z6dBL_A=93#`Z`axQ^K8A?U33t`0Mzx4>N7-oxj^uxG-F>tzOKs-392!e7=)!x`J3Q z7d1!9FRhZlPVAjtkI{+j4vnd&s3}mh$saRxc_&T+-uwa&hH&firwxK_P-3yDGiz;T zbeK@IEZOCmPtRt$Kj38MZFO!Yq$+p`5x$JY(}B4VwU~W%8MN7V>x;peFXb>EvzIH^ z6tWKyFDh9>?T--Od*>Bh+QEwN3+XC-W1jK8^7bjiAc+@eWe>-4B5g8Le!w@L=^;=K zdy-%Xn)-fTWRI&+zltvt<;VKvwB7lr6?XpqyQB+YUnL-oMS-~2{WoP1<$wxPsX}sC z97s1HVtL@x`-W!9{XigyW;8wnKk`=yOLC&6nE$i@DN*mIrX{&v#h&VzgeTK&-I~oX zlS3T@qkJQ>)Xo~W49P-wit}gl`cXLgD$npw7c%W29@t-+1$TcG6&EOKjA}H_;lV{* zzEBh6yLua7MV zB8MAZeZN-^_*JBSrH~k5*95|-|w*0yK$39V7R>HnK;v-sVazlsc-SHz@@u04V~3Vy$z_Bs{o7z zk!DH??nkSq!&0B7Xi~Iu9D0DVL)|){5KB;aWVL&rQ*yst_WlX4tJdgy<$9ETgJYWH z20k-+OZud=MWqRx)~WW^jzIc+tLUEjH` z2I>aRP+j;?h`+6OX|E*IsO?uJYD!`F*tWrRTn#d*p@(IZ2YVsySw*+-yQ;`JZibNU zmB8CRei7RJ{M68fRsUhrVTES$_tEc!Y-i_OvB=SG65t3@O9 zd=>3}nNMe6RfDY~5ZA-ely>e{yQ``Jt~17p{+uhGLiP7V91Y{I#}nHdXbq)trE6GS z*)j3jC7f464={hBW@M&o_sZc>>R47!X0a1axbAP7KRWjeX$Ko=r#>GsNTCzul_C^PpJ0nkbBT~W zR^Mo+%ZCNSrW^_bQne80l@iKp4Y&VbB)$xq6DLb~6lSm?P3tmye6{OX^g^rBNcg*J z2eUP^(;(=-8;mb)H+S-)7Y-ccP~UoNPEU50!ushp#OZcM|Q0i6ZjIfP&Wc_$G;x-L9Jvm*K`ekkAuWx>_otV+` zLfoUgd>>>S%;zagTdD?CKe2)(#$Ek=b!}}Fe4c3?YoE2@+s@AmdP)Os?#TiYJ0(&@ z^b?_#lnAb3I{|Lo$av=ENv}xyYQ0Rh6ju_TfuB3#cqeu`W9$^32krGOl;}7WcC}v0 zdLZftd1sEw%nb~uY^VA~8X>fbmzY(>-o(>2=WcxK?5A6q?zS_I&fN7+5i^cFAGk(! z*JNJWivJb5Jw?JCE`n&nk3GQ2pX1ulpdmGL!3zJA4Nyqz8D%y%OEc`nDz#*pwP*|J zII>{OGrDYI&!w%zq7|MJrO%d1PpXRA?6sMv#L0QJk(V@hNfKL@kssioO7Ca5FpEbf z@z-@!l2|Hx^r58JCm3)C>a9Ot38Z*(GKS;10K_upl060LG>dO>b6LXwNqP_I=k-xX z^+>0!=NhS-lYd^uw;b!4I63Q1m}?*aO$6cpznz;ej<%O(D6|;VMYWA6O^QAJn5!V1gj@*=a?62pJszXL@|TpmudrOnhWP9`!M8QL2| zQvQ&@CBDLbavkZXL9QY>upxRSLGAYpv5}cA^}~Kqaq6vvi=N#;P2Nxnuc(MH05YIp z9>}Q^>&?R!%xrMEO+;Q~=sn6Ce!WUk8Q1nC>otc4#rSJqJ9%PP&YF7cQ_AmIF9@dd z1->3cCuq|^{jymWD;7c~2vlOFy86<8y*i+hIDTvvq4!lyaQ`y86#@iK12`x14SutI z2S6>mrP0pQ>^dWLjsOs*!fveR`*?mj^ap@SMSkFR?&N zq^8vDztHVq@mI9xNwyu(<%nC$E~cnb1?X=l6huGV;*s~mDyU8u19oVP#YsYIun~#= zpuApnDN5{a;2H??qS6((UaBQ0nCE#AkYt~2ehpNF!kl`_i#I2PF1M0r89G?u^YnD| z%kv(yNmbJUJ@~+gz_xwE0~G8}>Kw1Qh}b|6N*pg-IDqu|LR(hmZJv;k%6yiegc>^S z2wdw+C8c7g(Ac>;;w%aNCAtA8oP=tXFgEJBObZc(&g}vu2q9O15c;EWEM@}K3>Q4y z=@cUH*N=37_#o;yQUgQab`Bh+BtMl?uJTsLZ?c9|ZM2|^+yC`l@jXfZs8*WS6OQc1 zJDcqX*n6O>cWOYr{P;gBo<3^itsUe@o@jb*uK}HKEkGJ1LFE&YCPG5{30gQ@>TeO+ zbY%$G7XZck{9}eO^^DhkWo(W|gqcCWl6wJixdeev%lQHI7P@X0Bg>-OR=`)8i<{cs zTEn$cNDwwYqJbf9f6a&u%~>hS;{)$K)%@T1J=eXljb~tprJD})9l@?Jtx%9Xi)=d> zG8|zmNPAZr&-h`Zoto8TXmpajMI!Ph*&^J`T{T|vy_5n0MmSV9cHFEP&0hok)$0Mp zUh3(}ND`mYgiI+*(>IDJm9u_hgXt(H$ifgcCnL!&bpOWG+%45B1P)%q;@x6@dA*zx zc#9jJuDPF?#ziliz46r2tUJsMJ}E&Yv?5J|@l~Ztvw8K*NwD7##T^McT#n5EJTVMG zv0XovA4aey@2RySLS2Cfs2`$*1;loDV|EfO=M3T8+KpSfZlx&rk5lpW+29YvLmT1m z1`*81R2S;NEE1XYGN$e3f5+I{AgN%kOrD>*sI@?d*PhI};F7XyjbPvE^YIE|*LYIhl(!V8 zbhT>7*_Xu~$F)yz&xx4a1$Iw|ijjgE(T1Uv=h>qg()&Q-2JN523*t!H70abbhUIJI_~#ULz}nA&4;7Ow&ovpx_SrpQ;IIAf%A9pt(6Wj<~mn z|7m~0CG~{G$rKMHJMN>y2sAt$(KM-N@CbA(KzLD!rN0ZrT)sB!QqF;2?viAh)%-$3HY>JymwQ-!cy zGKW>Xyaa+F3a}Zm&Gr>D(Q~60H5GdN?A=FFWgr0iRBaYun`GM&6nsitmLd>>*n=0X$YwRECc7F}8#=Doo_A7*;c7%^|Lb- zzt@cy*eZJHCH3YxN)ir z`!z&@E)1@7trL2;HmLjlo5^lDK~NvkH#JG>UyFZ0Jb`U#>wZwY5M`Ao3s#q<;?z3u zI7%p%L9Re|dc8z;GNiu#o(}V*CTDYppbwgmZ^0e*cyQ#;&gBOVr$@R^o~_ZHw= zE7$z@C`X2<0QHR(0wo+v9X4h9RuGQB)Z3*Y>Uev zMR?B*5-&jo`w+!l^Z5rBM}=OTwCbH)JWuWadRuJcsi6!E=AxJP>lx$Ka^LG(a`=bqCC|t9Vw^$-~ObBYK*CR_XohpeKZ3`NNM2} z#u(zK$zaBBg1EJEI>+)J50QA^L|ee@AuV{brHO*-56hVJJ9_H*kiZ7&R_l+^k#(`1PjZFBfQ4rhg4X@|I#V*_3 z6m+}SK3B(kjY*!3G-N~g7+U59khF?txYHhZ{3LGaZwS2_$8^W0W^Wm;i|Pkj@k#}n z^6xMd!a~PJ`Lx%!h$An#Vgi`RvtQ_Hd`K%TToGQ5N)WTZ@G_TK507Vqhs&3S8rTrTWI~~dmLcf>r0g z@5+-CK4g=IB!g7x{Ro;CO9!Qj=@}2L`;1l(Ct_ZdzHz<}y-Nh3>I_VGikMcd@(qy3 z?>-cnSOCMbVm!vf1@X277|e*&f}y|4HdK8XawxXY_X~AM*cCZt1l*!k@E(WbFVka~ zx<{cFr_$)ts?)pxm~7~;qTC%n-o;Pg^&3`0$r>=cG4Ss=gf)SOg*q5i7ytkO00000 z00005L*ZM!;(oUhE{0Z*9P*+i>-^h4M5lyb%u3G+(k(B)MI%BdCWwMH3PiiCNvqS! z|4d!~$94v{fso4cPSgL?MJ7ET@A3hFCR3J~ZuFeZ; z8e-%dt*P*y;v*skLU=A;iC!^H|73`G>Bvm;qd+E|SbWys)97cE?zp7-E5cO+m& zUNX$`?F!+$K55`>BnlKL9xYIIpNkm22j2nD?Z$A7GpdfR`;nDQRBe2~5;|<39}K_^ zSKMbBU9g@ecP7fq^k z)L=_=+%v|4qpE1@JhJc6OWxM~+_^H)zSX^kfok4P-t$Sm9XQdRw025$neZo;=lgNk za;A@7cD>-ZNWc+Le%BF=k7=o=;25B%X%DH@>Q|Odgx}(~9bF3atjy+7;(UL(Ip~YR z4ROj$kJCIrxd3)~nzkMzs<#XID{tbnw4Wv0J780n8HUt4hN*>BaoBG+c0Kc_CL~H4 z%}%kow6}F$S;GxEK6RFfVLa)ahI)lLKDlBfD66+4T`G8F6$`G!HCX+z;|^Q;9l7zG z{e(LCpV{y@bp3xGXomOn`dRIy`5d=d2_Fc#N(DzF<5s3?mEFxJIY!L5xZoO}hTh$o zfRHX&bu)3(fj}?0J%UkjU9|wua)62ncoGlj-BKPD8hcsB978GgD;u?Mz$@5v`#QL` zoAsvo4{IqY7t*GmUXTJj(WI@k{<19W?%Hdf)Y(5D7aD!aEAj#=3CN&D_@Nr1RH#ESm$p-ib5(@d zsabYO8#rrWiWwAN5OX^!XzH{09Bc~h?kk|>`DAX`0qh$g6xwyQO;Ms8jn>y1w!=aq zt8UH2l3R+B7L-zKp8Wac1*NM`MrvwPqSnj#XMkktn8zI`U$#5LNKWn2qZa8fU6ppJzfJSbP1zR0iKivTh-TC;|DF%{6j? zu!*0a7LQ;Y8z$mT(V50kKT)NC@L)cAQO8c9b}%H~W!1B@AN(p8bN{-^Il%i~G-_BH za;602An`h2iEr=h^Mqe%6s@PFTZ44?(;Dm5JQ$0;;melX5#(FPF0^r~Z++IB^}5hF?hkbw~jSe_5E}vA$@r()o$-`Cs<8hysJ?2k|5Q z!etWjKGy0>U3?}Q!H($TL@@~S64`4ZgF}9!5`0TNUefLY>JrEuS%zJFw2baJ5zjlL zE>-1w`{e9Q@-g{2#G||{>-15j(1&wa7uY)RA7{6P77fu3IAOE!#;L-1D`ey3*Q=b{jEr-jak$0%d*rTI=!s&x3)i(y3(j4x?UZ- zcfnd=HbtdFl&b_;0{!hZM!`0DwY}hiKl#BPO7`Yo`^w#h2L6A0YpDU%C+%Z+Aj;b^ zkfkWOE30jfujjV?pq$X;&tlxa3iY3sVwoN*R&yf^qJFz}b|b_Xs9$0EThTY5tws^N zO>ju;ML=t`idm+)Cpx|pP5FK#2w3ECNhL`BROc>>?gg6pe;IiPtUm=Ao_Q-P}oDwAaMXxe#e zWG=9%Y3*L1mmiZR*3vQbpJ?rTi*+KjLF=*ez~ELScsP)<`TwvD%pvxh5<9R-@6KY? zNN~D($pw>k7 zB{mjWB~f~I)U}$?NB-G}b1@x~?U)ZE$w7m-*#-FE9GC$8!mz7#>+K;Qx3#u&0OU^CW^U^~q4LYf3CZ zy=AI79brY5!SHl7gOq#tRZZC_xH%Xs-{c8$EKTPtx?(>`MAVI5oNaw$rd!v&HcUkt z4l%|8EO6Dp4`_==vzbFg>r}(BioEu;-YY$^Z% z000DRI{D3*#=B*Gz;r(#-3~)YG2ZkJ;*_1R@+uy>2YLfOQjsY_&@HtpqD)=2?5`P$ zCTxlsjw>J~UooG9@o=w(9%%l82!vQJk`9*njNvk<%l;RyS|3(o+U1H`=A2tgxTVwh z`ORaiRmiHeeDC(Cz4T}BL6W^8k1OVYXt=jl_0BB%;4V$b+&W|*+mpwV^E17`JeX)k ziN4i`1~4nu<`;A;hush`Tb4$(~Hu&AJ68< zYd@eTbq^#A02o>Xj3R?%A^2PE^2Iu*M~wwn6)nkX#NOU_B7W3ixDJ3~$QtCaOlfp| zFHN0BBTr*Hs@es*&e%f331P7_-8ui``1;Bj{Aiq6H?+YR=aNW6$o+@CwYhGZqk2w` z)qOv2NS9}dlWr__TpiSqn-HkpRv3lAtjsY&$Gj#o-<-J!%Dr~1u84-Tha{IdZAQ?% zce4&|Z>n1$o5mw#bxcWn*@{_2f}YX(#p=7A4dXY^(95(wl1OtmiR^2|(P5QvMe z_EGPzM>vFmCM1u1H=jCT43Ia&k)&zkhV|QqN3Ch~Cj9%Lg72u&uT+rP5+<)AqN%(r z(I061PC_iQk-Ow)p2oxapqbk&xb+F)WZ8ao&s5$5SeB1tF8NZIQd^!L%i@BfKjG;w ze{Tp)aJO;C`Xp`&us2Xqw@cvqxr){oV~>9gQT=nV;fFOB^PNmiOnaOR4)MznoYNS{ zF|#X?D7gP3_#?g-m)uX2T|_JYMvC#`YcngMc1@(2(6q>rxvLMj6h>_9x7C9GU)*r8 z*5!;ejN`<3cs@i)qgq2JhJ7=fC%>c0x%%K^#$-69 zP{EOot#vB~kqfCauv|#BMt`Y`7}J~mJ;SpK5w?u)=K9&eQA6C!KLfGguCf508wOzMH;IRQSJ*SLM1yRH=s7FANVPN?p$^B;1y>!>C8ihAz_QQ2KQ{)m zZM4HIPOmoQJfcsh=7*Zju&U-Yk5jAZVCQiWndMd2qLq_wAdQB=qJ=ta3T`{?1&IE; zO>}sm`2>D5L-b&lFfj(PRJ{8WM+lC9s5~n^4m1XWip5u*%?4kqO0k%yM3l)KEdT3= zw%38p-|G#8#`C$=lF2-=q_RmeU}`PWY2}5tFB%%tfLV{#$A262GcluP_X9>*bC`Jy zGeUp>0000vC?!X{R> z{;{q!GV3~0Y546U`N|fk94yDuo>5!{qe^h34%9l`(lNM8PQ+=#0!PC5CWWkqkya%; zYkE^Ydo}PC6b5Q=#xG}0o8yp+SbJK3o}M4qQJQ0 z%1M=_dalJz=d9eF`SE@UrY1YZGiVuSFdFe5sW2~iUT1L|hx1Q;=#{{(Utjp9pfDJd zX{;9X3iJKTo#49y1?wo{+nsfUY(I^DoY4;0jV5Nf>wokQ4iy)+AzK)z000003e$%G zjB@LBu+|N- z(0|Rhc$-IXL)%h{>esHXH?@dAtSy{{=a36_1XDz+gmvG2Nb8Dlkm8E8@yGXB0}f%292c_@x601E~FtQ zC3;wG#-2l%ftPP4;p3y&{Fyy6dWJ)sIlrCy6w&I!`KHw!8ki#=rpsr&3cCn}di+9) z4}DPO;s8Cp?9aBUM6@qM$`6~5NdVM~1c-X?KVPu?6YQT>ei3#Ege?GDVdTBi9%<7! zy*%BS^=4A@apzuiC{qM3!oUSHYjooU=*&94b)X_j54UVZ ziz*xxwQhZOGDAgeQC_67xjGqjQVKzUa>p=g&;maCb9q}6;RfNsj3QeittB$ptE{7^ z)b&7dL*LNwc32fOQnEt zE&?Pq+@Ri9gRILw0OUNMNx~r0^GtYd*+wzHr6&Mv`&G9vg|S7WVPjWGt@{V*ZyGy) z#s*M74wmFRIc;gSIV*toSS5zbW_y%vLfIf2Y}n-u-8b`4l#G))0HG*qBmG9B=E5bH z)lUKE%m{V9jag7PHnL^Gt=+yJ)#WF3Rw-psi#*0Vk`(X zvj~E&)#1*(r!O1u*=>spdj*jXf15;RrQ0v4k9#=mE~v7ty?PGI)#<1uAPD*gpf!|f z6A6Hk>N|~JQqKEtCoG~ao-Yu=7n01Si<>@v15Yk0TCK?$a)q5}L}(MS^r1o0Od6%R zYr#9g`6qUL~XdiX3L ze??M;i-ML}DSy;)n1vKk8ZR1NwKRwv{mCiH518KQ2Uj@8l)&zzxjT`H8quwyzE$~H z(`de@gM&~A%(jPE7be2RBHC2%W9F1iZk-j@L@^sWTz1B>Gc%-@U9dm`nq^NFFtt8? zNn`GWP<$V|6a!Fwh+M7vShFIX8xX$0xZw;o~t?gOYlD~{1q;-qnv=$Z|uV#NyUl_WOPGK!LJ zrx7#%FuG=YHi8Xj22b?TCJ$LT%w->+piamGI3gQM;JWm}H4&DTCLF2KbJZ?>Gb25Xk0 z6#;&^UZCFU9%(D9hyYgtcp1O^DFZGEM-eoG()Qk$KyBK}g5XmP3I5q-{vEiIIx6bGN}if@qJ6{FHYl`kfv$# z3oktBeiroWnP%>NuapIUSKRwJdnK21m7n5ogTB zhL3`4$IOp-WB88HfX)*&vd9S3)M_o~Ey6x(i?O11-@*KauBu_(arjDvyG!JHWgOp< z_VzN7HAhX#Ib8n|HlmT@2DQrRDjk`7{2>0dUe8iC!EEO?hfjKn;#Te9` zb(oaVvZLn=p-2wD(1}T7n|l_Fe-w%?8U7ov&C4F_cMc$Jf=i*;G|5`USm#;*QLjej zPiy6Sw09P<<7Z69tkII4mYI5&FSLbG)hvprxg5S0H-~1SvS$I=mpWYW0POE52N0 zSkr$q&*434X75YlTQURonZ`1X)|j9*BQ>=*+eUR?SwO=fK|N1va|k@>G@B6!F`OJz zU1MeH(6Z}Y9{+0qgZ|U>%u$QgDn()aoaZx253MaoVAz08y|n3|6sK_TdICUS)y?0R z`Zp=u`>z@*o`GXgggZfOX|PZ}%h+|r_D5!s=^)No%RuY8ZqIq9Yj(2Rj+wYZ5yO!2 z;8Thw9Klr$<-jOuM)ISsZ9!X;UsJ&dJF8jz{;yQUj&uHfEAw@%8<2B6#ad7A@dQ^# zu~K?qp%JkGOsHzF2G}dD=q6c)bpJ`Wr7aU%U`-Q&3?)_`1)j%m9LY>3f^V*m4uPeC zb?gvq=fGXa6}XKW?8+l|eTM6oeAN|w((1fnqNLF};~-5mjjh<^HLo1JXi(vs)i+zn**AJ@c3a3&E_aR`wf-&6&XqgSGL$n>YS#y+n|D9I7xAqsZHl90qtTW*h2E|q z^d-$A9Mg*5S}%({a0=xcoIb+o*iK7Y#^MRgHkX}(9t<;iLK;I_#PPD{+S1@h&{Q^i z#gI8@crF!VXhhK%tUvqpU~idbgY7YhyWX@q|^jj;Y!^<)+ zB7d03a1lKra3~6Yccze*h`jLNChTIrn<_PFPSgtP1s53bLo$5nQzPI200004QOlqx zCJTO4Y5mtIQ=2$H08(2GcAywkQ_UaI?-zmcN9n~Y5D2mVrMFjM!nM_zw@hPxjPndqy_U-5Y#|LzLqDL?iDSds+a=)_<10m^Er0 zo!ql%FaT1{gUsLlWrH28jT;S`7d(&8tHT{zj>O>=UFQg)N1pp+wIFjn!u|h#)0fU{ z0hep(j@~>H^QLPQs%zv$()lVQN{$TD-rcRa-dwd>C`==CrKKe3trm$=-KlSFMfzRC ze`7bmQrY+gT4;4w^0x^Z(|e1*62~nTBEfb5c5J@cEmhy+K;4}^AJ$?L!t0prv(qBv z`(w>~IPwV=8pBcbD=pZ^_4^fMn()3!N0T}M6`5j>z5GQQbgssaEC?Uy)7v1_D4dMb z%cpCg|Lofzk>&g%4hNTK*M#z<8i0@@`<>bvw1)I&xKf+CqKMFd{v_ z=*z`$B+ljbAJ=UN%c$78S8rp$P^QQEtM^EOZZ>fh>|aq>Blo$)s6rpfY2!y9zeO5= z5R?Jo4L74U6HA%txe)7qqaWByf`9!?hz#Aa1n*_W?%(Y>h|Zyi*q0V3i1lB8kH#lj!eaO)Veau}pvSN}FFcV`CmE0hdYi9o{*a zGaP=vCrUI?5GM~IOF;U)%`G`R)Z=*RW{i})KAp&V+(V|jo-+qW5NX{l+w(fNEcYT} z_u|+6>MTbN3d3q$9BRf4HtEkym;Blk2+E zg%mpOqH%*L9S(!SJXj#RDAkJk=nQmR^r(!wBJZ;UoYCdU?!0A=&oHrCMV2x{D0?p2 zO#F1dWIk|`-$S($K`!h))w``qC>Bb?j@BC&RfXiUJhVf8I;(m12*ygvL)9U}R`Ic< zXXVy4T=HQq8zr+w!QY7g#w7Q>$^=sItk@ymc@2)=U5^V`~G{K;20 zB_X={2}d}zr5>c{8EV=1aYJ#L*g3bz_`dWKbSdk?$7gb+C+bsPT{G%DN2Kq+<(9l; zRwd2kTG(3+=sQ7}+EbR5cnMikI>|x+u|gVxra_@bS9!XZ zW=$@o5agSMuz{=+9VVzeiB&^R*P6jhnx}y^&;TS})JZ@i^m0 zB6}IcsFT?-Cbp4)cam_Ciqk$6ZwQnXya)K1nUPM+Y$i@lQxZq(r$@$31-Ys|*C71` z0^*U}cVeR;I6-!Y5brBh1j8d&u#u6Ton=tHdG;(^9Pa=(L>X^<@9l3?cLrW@Lk-6Y z-S&a|5iDs%MokPRBIvPCQE*jtbYt8>uF~f2`xr{2RTFxOD|2_+E=BzTjZQjYcN24K ziDfu@TY!@{2(QvjMFR<|_wAe^)}Y-PNP-z2>h zVRW1*yZ3tkhq43(Kz31wXrhv9!=7U>-`tS4y^EAEl`W-wm&2ej0sUGT7Ij2^#higy z*_eQNGk>VPSiHkgzd@{w;V9YNOPuQ8JGcy93b>(i=i8fk_bSc2=6Zt#Sg$SIVj1%1 zslTKiQGfE;AdF1zNX6XY7ohY<>@08;8V#+Nx^TJa8BHA0kH$T*fN7V}L}}SOv6PyF zFT+nDwu&x?MuXO$LW7!Z9q@N(RCt5r<9y6oDU`~R_g*u0>`_-#Gdk08?Y4a{<30Qx zJ`7MIfpj&!P5p@y^V=f&U#xwLs#jy(uVB=eY8+TTN{Vc zFXHbHUUM``c;j092TkL|@1_ns^%SusgD~0h$L(Ih$cbh;`LA9g;^6`ZDHe*m7vk(K zl|5T-K@iv801p@w2vN@B zxkSYpXjGxNIH1%q7tNyo=c9^clcYzP++_{qb2S{bCOofC0$;ZVVI*q6(}^>_WiYY# zG7CY6C?-kLvds-V!R!^-!{==8mDmYeJZ8#u7wF5FE8`wEl-I4MFH2WvNiVOK89KchD^M z$XCLo9VuiTieL!Fzm_laxw@vC9U5CPEPlgzP|!EUb>*5dWLeYFC$EB+nn=IeYTrJybZ05i2P76&e*Q=Iqlyo@$^DEW|9+1U|F=X@domf8N zHNYOs^E3KpnOm@V884Vxakx{p`Mii|;eiiMv3zgULPh^{P{HH84I>{K|Y^(zJVLY0Yqbq_Wfs=17M(#eiLu@UsT z+&GA$4W!XquP?X4#De8`1awMB;fp@e{HXU2y=Z}RDQU|HF|sPn_HVUmqV=p+x2bt+ z{wMkw{-)<|PG*u$!`0ZtaeU|G*)Iqs;Hl}q+={tVDLmIH)d&pwNhqJ6(7kQw7t2V( z0Q0(+l585I*r?Ls#$)5s{`5>xc4H4`5)=Ut&+mh1Bx!a_UoS z+@i<$`}byiuY>&t?4%z`Rc=!Y!IQC(Ew`YNw+6(dD@9)%>{jXR5|Rg}r7h(qiaOgn zvxYQoOO{J%Ut1$gq;thZ!<^$jJ%6(<6q&an>b!kO<6_AC$H-Q~PyqNYNqa|qL_KeI zK%p=GOlxM#g6^xOf6Ssn*uEhi@A_^Hm!qS0V>&M4p)~)qE(K#NQpuWKa`J~7Nby2^ zb&ckKX|m=52tr#JM<-R^Us(|S*ZNi$CQ`^v>rd8pI$VkljxS@G%lxTjGls0Bbicsd1iT$xs_yGi(_j@hkd_hQ{hra*mXY0h6mp2;=BL$jcrw^48k6ba4 z;sqDo?mbuiV|g5UvX-W2(%WLQ@q70P0?NbN3=qj(U}@R)neX~tb;8_d!v*oUjffj6 z0R1Lkm726Xq~ks0)UL+1%vGpBGER}!K~uWJVa59Yv|*5C%*F2`!S7Qd`~~S=9U}LE zNSO(sDE;)r@dVO$?t~x5Lw)|AuB@@bZPR>5;jr9a(Et6ogz}c^lI_Ag|mj@^ufNG)6Az;c>7ClNd z&NENt(F+!mqSj-;l%_V#!7Z6IkZD-oUFWbmc+JFbEb>OExz82ep(WXZ&=zbjg2rho zpQ)ENK*!NN?c1oAbFziH%YdEcs>pju`?2>5BTEv@A(g8V82Z#Zdr^Ug+FZgn*tnU` zK(gje{{Mo}1+;RFD1(QUs%@H$RN48ATOU%-B7tc!3X;)4Z7ipFqfy3VQ8(SO%Q2ed zeeGe02c^QI1#KN%n2>&(#=&3vZ%e5GO6G_j8A}COQtwwk7&tY;qT zAibQ9NIoiJj z0ef`(;7E++LV562)#)0lHvBF*nsO$4n=oCNA(lK-sypv(tKTY~l>o32Ykuug#S7U` zHC(P;2>*)hAPDDA|2noTeU+J-u=&UXL&cYdUHZ-;@o@^iHiG;+-wllCnB;Z@*w!0Y ziDQ$Cj7r^rSVf^|yhWQa^XC&Nl+_lF7ph#m!SvzDWfw(ZgseF5K93tdE52>W+2uPW zDY`*Zu(*$}^pIl?Sx;_OB#RRL#R$~=8>VyOFvh9dDf$nkQs*mnw1~C0=-Lq@L`mce zFS1k9cE?BCIS9ay7k|0`{ngOdLC}-jCI)}|r6YH3-PVnf>!-86!`RBxHQQXt{tAET zPyos^r?wQ}X_8g0xY^W0qTTu^*_>G_0 zi!?Ft)nlhN7jln_bGiD~l`icJStO;L{kbl945b!^nhuoRXch&rOg{G37hc*tm@3;78*(@N}|C&Sv_; zZegtolTgKMNXpK<~->g=VvX%8T_Z0Og4s1q$8Bh3L)9Z*sl9d?+ z?8H(fB#E-J_Hr^1<)f00=9zc@?sfzy)q&i`IK8evA^N zU0XZnS^XLvInRavr!nfpeP4^ce7g(WD=B)0Ji2o_c|1$CmF&KjN#+!O^(@`)QwRzhaEA&4 z$GaJKkbG`l!GBO7X@yv{uy6vDtN%>So(T2&E9ECTF zmmL!h@Du(3dkgPJ2AlLQoH#u3z?Z|!az3RcAsf0;w}rL7Q1K6+<^njEgC)AJJKs_q zl*vOczo|ITG*FSS1YPe*zbQBXP2+l7RsiaYy8q$Wu0{J$9 z(HfBQ<_*eQ3dd0r!v+9Nf;{~#7RLa?_)`|w<8|aQpgSdu(P)(6md=DStx|g1ZAeI7 zyiRi^0+kArBNbAlQnyf8jhnN>SBkiktXgnvD~j$W3KOmSZbJ574W1OW9|`kz04-O} z45pG)J^1;WvQl3fTl@W(H(B)AO})-ZcW50MZj5tDYj_lcvKu>8mdP1pq);JG8IiuK zUiKZOD}0&6cDCVYEBkI@!q>N=r5b-zQe_t@#p%tY=kfaNpSZMP2dxiO_8`tsYv~Mf z9kmYUI@5rdLa#9hs6+TWDdarE5a<=8qQf5tV-&|wF}*iPP3bu>pJG6emn^;4T=CIu83h50J>$=RAR$dcH%0ScD%EL9SQ$Zd89x@krMRU#G zc;R`9CMLd6qHWPTBY5fK-*gfH$cX7#d6tOIBNu8m!i}W>;LEpiv&&x@m0V^IHN=0y zgS^iLfK+LC8jPapkBa>A#u2ibHm_`HLFfBo-ieQttY9HkZ;@os2-KYW*FTTJg3CN# z;rQ)6%Gm~Mk5S=}nBpRo4~*U=-an3{IR#hhsk9RCLXx6K!hlSVa_^C8YLP>YRhAp~xlA&hP7YyJre>fZafo+7l7uQ*9X32@`sBnr zxSOSizHLO1V*wgA5+J ztvIt!^z9qWTI^&)IDAsGQ%`V#d8)PIcS4XaI(Z^h4H|Wbss9^xdlUJ+SpdL+v@Nsb zz9WDEzOddp7M3Ip<{Hw$Xt<~rSWnrs46i>;H!Z-gzc!V6Gteoxad3*NtEE)*GvX50!R>cWegVaS)e* zz6LS;kzQa=MVztlm`kub)UV9IQfzqdqlr_TE`hWV#yBDkOaqg)xMU}*x~RH$yip(q z_KC?pCSZZVC;1}y(*Z@aiDN^^zg^bF>Ja_g%mWLi&Vjh`RTDiOb`5DA&3Wv2|RG4$UxqxqAa0= z?vE-q#60FlLuf-k{*D8~PczSVH)i`<-vE{%)S12i0Yjn$6h=e>lQu^ny_DI9Rg}VX z=*|p84_lT3_D=wL0CcRBv^dV8KVw{+S}EQHiWMW}6K`>m0#p3(jC+Zd50t7>g)dm` ziXYq^Oy)<4ebdVSmxqVhh~lmG%ty!*%*{`Y*z=WWjz%M zi$7=VAW^hn{Bf)ANLB>Kh+R9G@-u>-%@?4sSJ{;6tn#r`OodF-NwQlD_B0Pr7c+s= z7L>vuFPio}d*4G)cTb4pT>)ob`B0D_ce~CU94`-Y$8e9z0Vyc&URZid;!#ZfMSvYo z!XnEkjlINUgM-RRSHuFH0`mGp{DJB9JqrjIL=(b+_rK3`Loaq(E2yj5XG!L)=a6AN z$By1ho4^QYeb^bY2ThEl-+hNA1p@K)G)?bl*p~q Date: Sat, 18 Jul 2026 20:10:04 +0100 Subject: [PATCH 2/3] test(validate): check plugin.toml and Luau agree on setting keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a validator rule and its unit tests. Two failure modes that CI could not previously see: - Reading a key that plugin.toml does not declare. The host only resolves manifest-declared keys, so a typo in getConfig() yields nil rather than an error — the setting silently does nothing. - Declaring a setting nothing reads, which puts a dead control in the user's settings UI. The unused-setting half is skipped for plugins that call getConfig with a non-literal argument, since a key read through a variable cannot be seen and flagging it would be a false positive. Six plugins in this repo do that. The undeclared-read half still applies to them. Factors the existing Luau comment/string masking out of obsolete_config_accessors() into mask_luau(mask_strings=...), because reading a key literal needs comments masked but strings visible. obsolete_config_accessors() keeps its previous behaviour. Verified against all 24 plugins before wiring it in: no existing plugin trips either half, so this does not break CI for anyone. Mutation-tested — disabling the undeclared-read check, and disabling comment masking so a commented-out getConfig counts as a read, both fail the new tests. --- .github/workflows/test_validate_plugins.py | 70 +++++++++++++++++ .github/workflows/validate-plugins.py | 88 ++++++++++++++++++++-- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test_validate_plugins.py b/.github/workflows/test_validate_plugins.py index 5400fd2..43b67be 100644 --- a/.github/workflows/test_validate_plugins.py +++ b/.github/workflows/test_validate_plugins.py @@ -220,5 +220,75 @@ def test_requires_conditional_sections(self) -> None: self.assertTrue(any("## Settings" in error for error in self.validate_readme(without_settings))) +class SettingUsageTests(unittest.TestCase): + """plugin.toml and the Luau sources must agree on which settings exist.""" + + def validate(self, manifest: dict, sources: dict[str, str]) -> list[str]: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plugin_dir = root / "example" + plugin_dir.mkdir() + for name, contents in sources.items(): + path = plugin_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + validator = validate_plugins.Validator(root) + validator.validate_setting_usage(plugin_dir / "plugin.toml", plugin_dir, manifest) + return validator.errors + + def test_accepts_matching_manifest_and_source(self) -> None: + manifest = {"setting": [{"key": "interval"}, {"key": "notify"}]} + source = 'local a = noctalia.getConfig("interval")\nlocal b = noctalia.getConfig("notify")\n' + self.assertEqual(self.validate(manifest, {"main.luau": source}), []) + + def test_rejects_reading_an_undeclared_setting(self) -> None: + manifest = {"setting": [{"key": "interval"}]} + source = 'local a = noctalia.getConfig("interval")\nlocal b = noctalia.getConfig("intervl")\n' + errors = self.validate(manifest, {"main.luau": source}) + self.assertTrue(any("intervl" in error for error in errors)) + + def test_rejects_a_declared_setting_nothing_reads(self) -> None: + manifest = {"setting": [{"key": "interval"}, {"key": "orphan"}]} + source = 'local a = noctalia.getConfig("interval")\n' + errors = self.validate(manifest, {"main.luau": source}) + self.assertTrue(any("orphan" in error for error in errors)) + + def test_collects_entry_level_settings(self) -> None: + manifest = {"widget": [{"id": "w", "entry": "w.luau", "setting": [{"key": "bar_width"}]}]} + source = 'local w = noctalia.getConfig("bar_width")\n' + self.assertEqual(self.validate(manifest, {"w.luau": source}), []) + + def test_reads_across_multiple_sources(self) -> None: + manifest = {"setting": [{"key": "a"}, {"key": "b"}]} + sources = {"one.luau": 'noctalia.getConfig("a")\n', "two.luau": 'noctalia.getConfig("b")\n'} + self.assertEqual(self.validate(manifest, sources), []) + + def test_ignores_getconfig_inside_comments(self) -> None: + manifest = {"setting": [{"key": "interval"}]} + source = 'noctalia.getConfig("interval")\n-- noctalia.getConfig("ghost")\n' + self.assertEqual(self.validate(manifest, {"main.luau": source}), []) + + def test_ignores_getconfig_inside_block_comments(self) -> None: + manifest = {"setting": [{"key": "interval"}]} + source = 'noctalia.getConfig("interval")\n--[[ noctalia.getConfig("ghost") ]]\n' + self.assertEqual(self.validate(manifest, {"main.luau": source}), []) + + def test_skips_unused_check_when_a_key_is_dynamic(self) -> None: + # A key read through a variable cannot be seen, so "declared but unread" would be a + # false positive. The undeclared-read check still applies. + manifest = {"setting": [{"key": "interval"}, {"key": "maybe_used"}]} + source = 'local k = "interval"\nnoctalia.getConfig(k)\n' + self.assertEqual(self.validate(manifest, {"main.luau": source}), []) + + def test_still_flags_undeclared_reads_when_a_key_is_dynamic(self) -> None: + manifest = {"setting": [{"key": "interval"}]} + source = 'noctalia.getConfig(k)\nnoctalia.getConfig("ghost")\n' + errors = self.validate(manifest, {"main.luau": source}) + self.assertTrue(any("ghost" in error for error in errors)) + + def test_accepts_a_plugin_with_no_settings(self) -> None: + self.assertEqual(self.validate({}, {"main.luau": "local x = 1\n"}), []) + + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/validate-plugins.py b/.github/workflows/validate-plugins.py index 15b86b3..6cb1454 100644 --- a/.github/workflows/validate-plugins.py +++ b/.github/workflows/validate-plugins.py @@ -156,6 +156,10 @@ OBSOLETE_CONFIG_ACCESSOR_RE = re.compile( r"\b(barWidget|desktopWidget|panel|launcher)\s*\.\s*getConfig\b" ) +# getConfig("key") / getConfig('key') — the argument must be a plain literal to be read. +CONFIG_KEY_LITERAL_RE = re.compile(r"\bgetConfig\s*\(\s*[\"']([^\"']+)[\"']\s*\)") +# Any getConfig call whose argument is not a bare literal (a variable, a concatenation). +CONFIG_KEY_DYNAMIC_RE = re.compile(r"\bgetConfig\s*\(\s*(?![\"'][^\"']*[\"']\s*\))") def is_non_empty_string(value: Any) -> bool: @@ -257,8 +261,13 @@ def section_body(markdown: str, headings: list[tuple[int, str, int, int]], index return markdown[body_start:body_end].strip() -def obsolete_config_accessors(source: str) -> list[tuple[str, int]]: - """Find removed entry-specific getConfig aliases outside Luau comments and strings.""" +def mask_luau(source: str, *, mask_strings: bool) -> str: + """Blank out Luau comments, and optionally string literals, preserving offsets. + + Newlines survive so line numbers still line up. Callers that only need to spot code + pass mask_strings=True; callers that need to read a literal argument pass False and + keep comments masked, so a getConfig call inside a comment is still ignored. + """ visible = list(source) length = len(source) index = 0 @@ -286,7 +295,8 @@ def mask(start: int, end: int) -> None: if source.startswith("[[", index): end = source.find("]]", index + 2) end = length if end == -1 else end + 2 - mask(index, end) + if mask_strings: + mask(index, end) index = end continue @@ -300,19 +310,37 @@ def mask(start: int, end: int) -> None: end += 1 if source[end - 1] == quote: break - mask(index, end) + if mask_strings: + mask(index, end) index = end continue index += 1 - code = "".join(visible) + return "".join(visible) + + +def obsolete_config_accessors(source: str) -> list[tuple[str, int]]: + """Find removed entry-specific getConfig aliases outside Luau comments and strings.""" + code = mask_luau(source, mask_strings=True) return [ (f"{match.group(1)}.getConfig", code.count("\n", 0, match.start()) + 1) for match in OBSOLETE_CONFIG_ACCESSOR_RE.finditer(code) ] +def config_keys_read(source: str) -> tuple[set[str], bool]: + """Setting keys read via noctalia.getConfig, and whether any call is not a literal. + + A non-literal call — getConfig(key), getConfig("a" .. b) — means the set is not the + whole story, so callers must not conclude a declared setting is unused. + """ + code = mask_luau(source, mask_strings=False) + keys = set(CONFIG_KEY_LITERAL_RE.findall(code)) + dynamic = CONFIG_KEY_DYNAMIC_RE.search(code) is not None + return keys, dynamic + + def webp_dimensions(header: bytes) -> tuple[int, int] | None: """Width and height from a WebP header, or None if it is not one we can read. @@ -1122,6 +1150,55 @@ def validate_luau_api(self, plugin_dir: Path) -> None: f"'{accessor}' on line {line} was removed; use noctalia.getConfig", ) + def declared_setting_keys(self, manifest: dict[str, Any]) -> set[str]: + keys: set[str] = set() + for setting in manifest.get("setting") or []: + if isinstance(setting, dict) and is_non_empty_string(setting.get("key")): + keys.add(setting["key"]) + for kind in ENTRY_TYPES: + for entry in manifest.get(kind) or []: + if not isinstance(entry, dict): + continue + for setting in entry.get("setting") or []: + if isinstance(setting, dict) and is_non_empty_string(setting.get("key")): + keys.add(setting["key"]) + return keys + + def validate_setting_usage(self, manifest_path: Path, plugin_dir: Path, manifest: dict[str, Any]) -> None: + """Keep plugin.toml and the Luau sources agreed on which settings exist. + + Reading an undeclared key is a silent failure at runtime: the host only resolves + manifest-declared keys, so a typo yields nil rather than an error. The reverse — + a declared setting nothing reads — puts a dead control in the user's settings UI. + """ + declared = self.declared_setting_keys(manifest) + + read: set[str] = set() + dynamic = False + for source_path in sorted(plugin_dir.rglob("*.luau")): + try: + source = source_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue # already reported by validate_luau_api + source_keys, source_dynamic = config_keys_read(source) + read |= source_keys + dynamic = dynamic or source_dynamic + + for key in sorted(read - declared): + self.add_error( + manifest_path, + f"getConfig('{key}') reads a setting that is not declared in plugin.toml", + ) + + # Only meaningful when every call site is a literal; otherwise a key may well be + # read through a variable and flagging it would be a false positive. + if not dynamic: + for key in sorted(declared - read): + self.add_error( + manifest_path, + f"setting '{key}' is declared but never read via noctalia.getConfig", + ) + def validate_no_symlinks(self, manifest_path: Path, plugin_dir: Path) -> None: for path in plugin_dir.rglob("*"): if path.is_symlink(): @@ -1140,6 +1217,7 @@ def validate_manifest(self, manifest_path: Path) -> None: self.validate_thumbnail(manifest_path, plugin_dir) self.validate_readme(plugin_dir, manifest) self.validate_luau_api(plugin_dir) + self.validate_setting_usage(manifest_path, plugin_dir, manifest) self.validate_no_symlinks(manifest_path, plugin_dir) if "setting" in manifest: From 9a984a70817ce7cd5554fc7f6aea0d823f0ceb8e Mon Sep 17 00:00:00 2001 From: ioan Date: Sat, 18 Jul 2026 23:06:56 +0100 Subject: [PATCH 3/3] fix(cpu-bars): hold the last frame when a sample is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget vanished for over a second whenever the bar reloaded. Every bar reload recreates the widget, and update() called setVisible(false) whenever cpu.cores came back empty, so a momentary gap in the data became a visible disappearance rather than a stale frame. Keep whatever is already drawn instead, and only hide when nothing has ever been rendered — the genuine first-tick case, where there is no previous reading to diff against. The host half of this is fixed separately in noctalia: releasing the last per-core reference used to clear the samples, and recreating a widget drops the refcount to zero for a few milliseconds. Either fix alone hides the symptom; this one also makes the widget robust against any future gap. --- cpu-bars/cpu_bars.luau | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/cpu-bars/cpu_bars.luau b/cpu-bars/cpu_bars.luau index e12a4f1..58635ec 100644 --- a/cpu-bars/cpu_bars.luau +++ b/cpu-bars/cpu_bars.luau @@ -9,6 +9,10 @@ local MIN_TICK_MS: number = 500 -- reads the previous second's sample; this bias keeps every update on the correct side. local TARGET_OFFSET_MS: number = 6 +-- Whether a bar row has ever been drawn, so a later empty sample holds the last frame +-- rather than blanking the widget. +local hasRendered: boolean = false + -- Tick alignment state. See scheduleNextTick(). local lastOffset: number? = nil local lastRequest: number? = nil @@ -59,13 +63,17 @@ function update() end local cores: { number } = stats.cpu.cores or {} - -- The first sample after enabling has no previous reading to diff against, so cores is - -- empty for one tick. Keep the widget hidden rather than flashing an empty row. + -- No sample yet: on the very first tick after enabling there is no previous reading to + -- diff against. Hold whatever is already drawn instead of hiding, so a momentary gap + -- never blanks the widget; only hide when nothing has ever been drawn. if #cores == 0 then - barWidget.setVisible(false) + if not hasRendered then + barWidget.setVisible(false) + end scheduleNextTick() return end + hasRendered = true barWidget.setVisible(true) local vertical: boolean = barWidget.isVertical()