🔥 Sync 2026-09-29 06:58:20

This commit is contained in:
github-actions[bot] committed 2026-09-29 06:58:20 +08:00
1 parent bc5412ae20
commit 4dac5ce0fc
22 files changed
+459 -121

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# 변경 기록
## [0.2.24-r2] - 2026-09-29
### 개선
- `새 펌웨어가 있습니다.` 안내에서 내부 OpenWrt 기반 버전 표기를 제거하고 SmartSafeHub 펌웨어 버전, 이미지 크기와 배포 채널만 표시하도록 단순화했습니다.
- OpenWrt 기반 버전이 확인되지 않을 때 사용자에게 `OpenWrt 미확인`이 노출되지 않도록 했습니다.
### 테스트
- 사용 가능한 펌웨어 요약에 OpenWrt 기반 버전이 다시 노출되지 않고 SmartSafeHub 펌웨어 버전과 이미지 크기가 유지되는지 UI contract 테스트를 추가했습니다.
## [0.2.24-r1] - 2026-09-28
### 수정
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-smartsafehub
PKG_VERSION:=0.2.24
PKG_RELEASE:=3
PKG_RELEASE:=4
PKG_MAINTAINER:=Beomjun Kang <kals323@gmail.com>
PKG_LICENSE:=GPL-3.0-or-later
@@ -372,6 +372,11 @@ export function FirmwareUpdatesCard({
? '새 펌웨어'
: '최신 버전'}
</dd>
{data.release?.buildId ? (
<span class="mt-1 block break-all text-[11px] leading-5 text-slate-400">
빌드 ID {data.release.buildId}
</span>
) : null}
</div>
<div class="bg-white p-4">
<dt class="text-[11px] font-black uppercase tracking-[0.16em] text-slate-400">Last check</dt>
@@ -388,7 +393,7 @@ export function FirmwareUpdatesCard({
<p class="m-0 text-xs font-extrabold uppercase tracking-[0.16em] text-sky-700">Available firmware</p>
<h3 class="mt-2 mb-0 text-lg font-black text-slate-950">새 펌웨어가 있습니다.</h3>
<p class="mt-2 mb-0 text-sm leading-6 text-slate-600">
펌웨어 {data.release.version || '미확인'} · OpenWrt {data.release.openwrtVersion || '미확인'} · {formatBytes(data.release.sysupgrade.sizeBytes)} · {(data.release.channel || data.settings.channel) === 'beta' ? 'Beta' : 'Stable'}
펌웨어 {data.release.version || '미확인'} · {formatBytes(data.release.sysupgrade.sizeBytes)} · {(data.release.channel || data.settings.channel) === 'beta' ? 'Beta' : 'Stable'}
</p>
</div>
<span class="shrink-0 rounded-full bg-white px-3 py-1.5 text-xs font-extrabold text-sky-700 ring-1 ring-inset ring-sky-200">
+1 -1
View File
@@ -44,7 +44,7 @@ function installBootstrap(sessionId: string, host: HTMLElement): void {
sessionId,
rpcUrl: luciUrl('/admin/ubus'),
assetBase: host.dataset.assetBase ?? '/luci-static/smartsafehub/',
assetVersion: host.dataset.assetVersion ?? '0.2.24-r1',
assetVersion: host.dataset.assetVersion ?? '0.2.24-r2',
locale: document.documentElement.lang || 'ko',
});
}
@@ -10,16 +10,16 @@
<meta name="robots" content="noindex,nofollow,noarchive">
<meta name="referrer" content="same-origin">
<title>SmartSafeHub</title>
<link rel="icon" type="image/svg+xml" href="/luci-static/smartsafehub/favicon.svg?v=0.2.24-r1">
<link rel="icon" type="image/svg+xml" href="/luci-static/smartsafehub/favicon.svg?v=0.2.24-r2">
</head>
<body style="margin:0;background:#f8fafc;min-height:100vh;">
<div
id="smartsafehub-entry-root"
data-asset-base="/luci-static/smartsafehub/"
data-asset-version="0.2.24-r1"
data-asset-version="0.2.24-r2"
style="display:block;width:100%;min-height:100vh;"
></div>
<noscript>SmartSafeHub를 사용하려면 JavaScript를 활성화해 주세요.</noscript>
<script type="module" src="/luci-static/smartsafehub/app.js?v=0.2.24-r1"></script>
<script type="module" src="/luci-static/smartsafehub/app.js?v=0.2.24-r2"></script>
</body>
</html>
@@ -1,3 +1,3 @@
var e,t,n,r,i,a,o,s,c,l,u,d,f,p,m={},h=[],g=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,_=Array.isArray;function v(e,t){for(var n in t)e[n]=t[n];return e}function y(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function b(t,n,r){var i,a,o,s={};for(o in n)o==`key`?i=n[o]:o==`ref`?a=n[o]:s[o]=n[o];if(arguments.length>2&&(s.children=arguments.length>3?e.call(arguments,2):r),typeof t==`function`&&t.defaultProps!=null)for(o in t.defaultProps)s[o]===void 0&&(s[o]=t.defaultProps[o]);return x(t,s,i,a,null)}function x(e,r,i,a,o){var s={type:e,props:r,key:i,ref:a,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:o??++n,__i:-1,__u:0};return o==null&&t.vnode!=null&&t.vnode(s),s}function S(e){return e.children}function C(e,t){this.props=e,this.context=t}function w(e,t){if(t==null)return e.__?w(e.__,e.__i+1):null;for(var n;t<e.__k.length;t++)if((n=e.__k[t])!=null&&n.__e!=null)return n.__e;return typeof e.type==`function`?w(e):null}function T(e){if(e.__P&&e.__d){var n=e.__v,r=n.__e,i=[],a=[],o=v({},n);o.__v=n.__v+1,t.vnode&&t.vnode(o),ie(e.__P,o,n,e.__n,e.__P.namespaceURI,32&n.__u?[r]:null,i,r??w(n),!!(32&n.__u),a),o.__v=n.__v,o.__.__k[o.__i]=o,oe(i,o,a),n.__e=n.__=null,o.__e!=r&&E(o)}}function E(e){if((e=e.__)!=null&&e.__c!=null)return e.__e=e.__c.base=null,e.__k.some(function(t){if(t!=null&&t.__e!=null)return e.__e=e.__c.base=t.__e}),E(e)}function D(e){(!e.__d&&(e.__d=!0)&&r.push(e)&&!O.__r++||i!=t.debounceRendering)&&((i=t.debounceRendering)||a)(O)}function O(){try{for(var e,t=1;r.length;)r.length>t&&r.sort(o),e=r.shift(),t=r.length,T(e)}finally{r.length=O.__r=0}}function k(e,t,n,r,i,a,o,s,c,l,u){var d,f,p,g,_,v,y=r&&r.__k||h,b=t.length;for(c=A(n,t,y,c,b),d=0;d<b;d++)(p=n.__k[d])!=null&&(f=p.__i!=-1&&y[p.__i]||m,p.__i=d,v=ie(e,p,f,i,a,o,s,c,l,u),g=p.__e,p.ref&&f.ref!=p.ref&&(f.ref&&le(f.ref,null,p),u.push(p.ref,p.__c||g,p)),_==null&&g!=null&&(_=g),4&p.__u?(c=ee(p,c,e),f.__e&&(f.__e=null)):typeof p.type==`function`&&v!==void 0?c=v:g&&(c=g.nextSibling),p.__u&=-7);return n.__e=_,c}function A(e,t,n,r,i){var a,o,s,c,l,u=n.length,d=u,f=0;for(e.__k=Array(i),a=0;a<i;a++)(o=t[a])!=null&&typeof o!=`boolean`&&typeof o!=`function`?(typeof o==`string`||typeof o==`number`||typeof o==`bigint`||o.constructor==String?o=e.__k[a]=x(null,o,null,null,null):_(o)?o=e.__k[a]=x(S,{children:o},null,null,null):o.constructor===void 0&&o.__b>0?o=e.__k[a]=x(o.type,o.props,o.key,o.ref?o.ref:null,o.__v):e.__k[a]=o,c=a+f,o.__=e,o.__b=e.__b+1,s=null,(l=o.__i=te(o,n,c,d))!=-1&&(d--,(s=n[l])&&(s.__u|=2)),s==null||s.__v==null?(l==-1&&(i>u?f--:i<u&&f++),typeof o.type!=`function`&&(o.__u|=4)):l!=c&&(l==c-1?f--:l==c+1?f++:(l>c?f--:f++,o.__u|=4))):e.__k[a]=null;if(d)for(a=0;a<u;a++)(s=n[a])!=null&&!(2&s.__u)&&(s.__e==r&&(r=w(s)),ue(s,s));return r}function ee(e,t,n){var r,i;if(typeof e.type==`function`){for(r=e.__k,i=0;r&&i<r.length;i++)r[i]&&(r[i].__=e,t=ee(r[i],t,n));return t}e.__e!=t&&(t&&e.type&&!t.parentNode&&(t=w(e)),t=n.insertBefore(e.__e,t||null));do t=t&&t.nextSibling;while(t!=null&&t.nodeType==8);return t}function j(e,t){return t=t||[],e==null||typeof e==`boolean`||(_(e)?e.some(function(e){j(e,t)}):t.push(e)),t}function te(e,t,n,r){var i,a,o,s=e.key,c=e.type,l=t[n],u=l!=null&&!(2&l.__u);if(l===null&&s==null||u&&s==l.key&&c==l.type)return n;if(r>+!!u){for(i=n-1,a=n+1;i>=0||a<t.length;)if((l=t[o=i>=0?i--:a++])!=null&&!(2&l.__u)&&s==l.key&&c==l.type)return o}return-1}function ne(e,t,n){t[0]==`-`?e.setProperty(t,n??``):e[t]=n==null?``:typeof n!=`number`||g.test(t)?n:n+`px`}function M(e,t,n,r,i){var a,o;n:if(t==`style`){if(typeof n==`string`)e.style.cssText=n;else{if(typeof r==`string`&&(e.style.cssText=r=``),r)for(t in r)n&&t in n||ne(e.style,t,``);if(n)for(t in n)r&&n[t]==r[t]||ne(e.style,t,n[t])}}else if(t[0]==`o`&&t[1]==`n`)a=t!=(t=t.replace(u,`$1`)),o=t.toLowerCase(),t=o in e||t==`onFocusOut`||t==`onFocusIn`?o.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+a]=n,n?r?n[l]=r[l]:(n[l]=d,e.addEventListener(t,a?p:f,a)):e.removeEventListener(t,a?p:f,a);else{if(i==`http://www.w3.org/2000/svg`)t=t.replace(/xlink(H|:h)/,`h`).replace(/sName$/,`s`);else if(t!=`width`&&t!=`height`&&t!=`href`&&t!=`list`&&t!=`form`&&t!=`tabIndex`&&t!=`download`&&t!=`rowSpan`&&t!=`colSpan`&&t!=`role`&&t!=`popover`&&t in e)try{e[t]=n??``;break n}catch{}typeof n==`function`||(n==null||!1===n&&t[4]!=`-`?e.removeAttribute(t):e.setAttribute(t,t==`popover`&&n==1?``:n))}}function re(e){return function(n){if(this.l){var r=this.l[n.type+e];if(n[c]==null)n[c]=d++;else if(n[c]<r[l])return;return r(t.event?t.event(n):n)}}}function ie(e,n,r,i,a,o,s,c,l,u){var d,f,p,m,g,b,x,T,E,D,O,A,ee,j,te,ne,M=n.type;if(n.constructor!==void 0)return null;128&r.__u&&(l=!!(32&r.__u),o=[c=n.__e=r.__e]),(d=t.__b)&&d(n);n:if(typeof M==`function`){f=s.length;try{if(E=n.props,D=M.prototype&&M.prototype.render,O=(d=M.contextType)&&i[d.__c],A=d?O?O.props.value:d.__:i,r.__c?T=(p=n.__c=r.__c).__=p.__E:(D?n.__c=p=new M(E,A):(n.__c=p=new C(E,A),p.constructor=M,p.render=de),O&&O.sub(p),p.stLine truncated
`)>-1?e.split(`
`):e}function Cm(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function wm(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=gl(t.bodyFont),l=gl(t.titleFont),u=gl(t.footerFont),d=a.length,f=i.length,p=r.length,m=hl(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,Z(e.title,y),n.font=c.string,Z(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,Z(r,e=>{Z(e.before,y),Z(e.lines,y),Z(e.after,y)}),v=0,n.font=u.string,Z(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function Tm(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function Em(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function Dm(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),Em(l,e,t,n)&&(l=`center`),l}function Om(e,t,n){let r=n.yAlign||t.yAlign||Tm(e,n);return{xAlign:n.xAlign||t.xAlign||Dm(e,t,n,r),yAlign:r}}function km(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Am(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function jm(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=ml(o),m=km(t,s),h=Am(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:ic(m,0,r.width-t.width),y:ic(h,0,r.height-t.height)}}function Mm(e,t,n){let r=hl(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Nm(e){return xm([],Sm(e))}function Pm(e,t,n){return yl(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function Fm(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Im={beforeTitle:ds,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:ds,beforeBody:ds,beforeLabel:ds,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return J(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:ds,afterBody:ds,beforeFooter:ds,footer:ds,afterFooter:ds};function Lm(e,t,n,r){let i=e[t].call(n,r);return i===void 0?Im[t].call(n,r):i}var Rm=class extends Af{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new zu(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=Pm(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=Lm(n,`beforeTitle`,this,e),i=Lm(n,`title`,this,e),a=Lm(n,`afterTitle`,this,e),o=[];return o=xm(o,Sm(r)),o=xm(o,Sm(i)),o=xm(o,Sm(a)),o}getBeforeBody(e,t){return Nm(Lm(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return Z(e Line truncated
`):e}function Cm(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function wm(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=gl(t.bodyFont),l=gl(t.titleFont),u=gl(t.footerFont),d=a.length,f=i.length,p=r.length,m=hl(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,Z(e.title,y),n.font=c.string,Z(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,Z(r,e=>{Z(e.before,y),Z(e.lines,y),Z(e.after,y)}),v=0,n.font=u.string,Z(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function Tm(e,t){let{y:n,height:r}=t;return n<r/2?`top`:n>e.height-r/2?`bottom`:`center`}function Em(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function Dm(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),Em(l,e,t,n)&&(l=`center`),l}function Om(e,t,n){let r=n.yAlign||t.yAlign||Tm(e,n);return{xAlign:n.xAlign||t.xAlign||Dm(e,t,n,r),yAlign:r}}function km(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function Am(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:r-=t===`bottom`?i+n:i/2,r}function jm(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=ml(o),m=km(t,s),h=Am(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:ic(m,0,r.width-t.width),y:ic(h,0,r.height-t.height)}}function Mm(e,t,n){let r=hl(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function Nm(e){return xm([],Sm(e))}function Pm(e,t,n){return yl(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function Fm(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var Im={beforeTitle:ds,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex<r)return n[t.dataIndex]}return``},afterTitle:ds,beforeBody:ds,beforeLabel:ds,label(e){if(this&&this.options&&this.options.mode===`dataset`)return e.label+`: `+e.formattedValue||e.formattedValue;let t=e.dataset.label||``;t&&(t+=`: `);let n=e.formattedValue;return J(n)||(t+=n),t},labelColor(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{borderColor:t.borderColor,backgroundColor:t.backgroundColor,borderWidth:t.borderWidth,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(e){let t=e.chart.getDatasetMeta(e.datasetIndex).controller.getStyle(e.dataIndex);return{pointStyle:t.pointStyle,rotation:t.rotation}},afterLabel:ds,afterBody:ds,beforeFooter:ds,footer:ds,afterFooter:ds};function Lm(e,t,n,r){let i=e[t].call(n,r);return i===void 0?Im[t].call(n,r):i}var Rm=class extends Af{constructor(e){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=e.chart,this.options=e.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(e){this.options=e,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){let e=this._cachedAnimations;if(e)return e;let t=this.chart,n=this.options.setContext(this.getContext()),r=n.enabled&&t.options.animation&&n.animations,i=new zu(this.chart,r);return r._cacheable&&(this._cachedAnimations=Object.freeze(i)),i}getContext(){return this.$context||(this.$context=Pm(this.chart.getContext(),this,this._tooltipItems))}getTitle(e,t){let{callbacks:n}=t,r=Lm(n,`beforeTitle`,this,e),i=Lm(n,`title`,this,e),a=Lm(n,`afterTitle`,this,e),o=[];return o=xm(o,Sm(r)),o=xm(o,Sm(i)),o=xm(o,Sm(a)),o}getBeforeBody(e,t){return Nm(Lm(t.callbacks,`beforeBody`,this,e))}getBody(e,t){let{callbacks:n}=t,r=[];return Z(e Line truncated
@@ -408,8 +408,13 @@ grep -Fq '빌드 ID {data.current.buildId}' "$FIRMWARE_CARD" || \
fail 'firmware UI must keep build_id as secondary diagnostic identity'
grep -Fq 'data.release?.version' "$FIRMWARE_CARD" || \
fail 'firmware UI must use the Hub release version for the available firmware version'
grep -Fq "펌웨어 {data.release.version || '미확인'} · OpenWrt" "$FIRMWARE_CARD" || \
fail 'available firmware details must distinguish product firmware version from the OpenWrt base version'
grep -Fq '빌드 ID {data.release.buildId}' "$FIRMWARE_CARD" || \
fail 'available firmware version must keep build_id as secondary diagnostic identity'
grep -Fq "펌웨어 {data.release.version || '미확인'} · {formatBytes(data.release.sysupgrade.sizeBytes)}" "$FIRMWARE_CARD" || \
fail 'available firmware details must show product firmware version and image size without OpenWrt base metadata'
if grep -Fq "펌웨어 {data.release.version || '미확인'} · OpenWrt" "$FIRMWARE_CARD"; then
fail 'available firmware summary must not expose the OpenWrt base version'
fi
grep -Fq "(data.release.channel || data.settings.channel) === 'beta' ? 'Beta' : 'Stable'" "$FIRMWARE_CARD" || fail 'available firmware details must prefer the release firmware channel over package update settings'
grep -Fq "[class~='bg-sky-50']" "$APP_CSS" || fail 'dark mode must define a sky surface override for the firmware update panel'
grep -Fq "[class~='text-sky-700']" "$APP_CSS" || fail 'dark mode must define a sky text override for the firmware update panel'
+24
View File
@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file.
## [2.3.0-r2] - 新增 Clash 原版与 WireGuard .conf 输出,输出层清理
- 新增输出格式 **WireGuard / AmneziaWG `.conf`**(`target=wgconf`,别名 `wg` / `wireguard` / `amneziawg` / `amnezia` / `conf`)
- 与 `parser.parse_wireguard_conf` 互为逆操作:输出 `[Interface]` / `[Peer]` 标准 wg-quick 配置
- `[Interface]`:PrivateKey / Address(IPv4 + IPv6 合并)/ ListenPort / MTU / DNS / AmneziaWG 参数(键名首字母大写,按键名排序保证可 diff)
- `[Peer]`:PublicKey / PresharedKey / AllowedIPs / Endpoint / PersistentKeepalive
- IPv6 Endpoint 自动加方括号(`[2001:db8::1]:51821`)
- 仅输出 wireguard 节点;无 wireguard 节点时明确报错(而非下载到空文件)
- 已知有损项:`Reserved` 不是 wg-quick 标准键,导出时不写出(`reserved` 仍保留在 clash.meta / sing-box / URI 输出中)
- 新增模块 `root/usr/share/substore/output_wireguard_conf.lua`
- 新增输出格式 **Clash 原版**(`target=clash`),面向 Dreamacro Clash / ClashX / Clash for Windows
- 过滤原版不支持的协议(vless / hysteria2 / hysteria / tuic / wireguard),其余复用 Clash.Meta 的 YAML 生成
- 采用排除法而非白名单,避免误丢原版其实支持的协议
- ⚠️ **行为变更**:`target=clash` 语义由「Clash.Meta」改为「Clash 原版」。原先使用 `?target=clash` 拉取 Clash.Meta 配置的用户请改用 `target=clashmeta`(或 `yaml` / `mihomo`)
- ⚠️ **行为变更(无感)**:未指定 `target` 时的默认格式显式固定为 `clashmeta`,与历史默认行为一致
- 输出层清理(`output.lua`)
- 删除死代码 `to_clash_yaml` / `to_json` / `to_base64`(无任何调用点;且 `to_clash_yaml` 会把 `type:` 写成原始协议名,产出非法 YAML)
- 删除随之失效的 `util` / `node` require
- 新增 `M.FORMAT_OPTIONS` 作为格式清单的唯一数据源,`subscriptions.htm` 与 `output.htm` 两处硬编码 `<option>` 列表改为遍历生成,避免新增格式时漏改模板
- 默认格式提取为 `DEFAULT_FORMAT` 常量,供 content-type / 扩展名 / 生成三处共用
- 新增测试 `tests/output_new_formats_test.lua`(66 项):格式注册表一致性(每个 UI 选项都有别名、content-type、扩展名与分发分支)、Clash 原版协议过滤、`.conf` 结构与 AmneziaWG 键排序、IPv6 方括号、无 wireguard 报错、`.conf` 导出 → 重新导入的完整往返
- `tests/core_link_test.lua` 的目标格式列表改为从 `output.FORMAT_OPTIONS` 派生,新增格式自动纳入覆盖
- 版本号 2.3.0-r1 → 2.3.0-r2;README.md / README.en.md / docs/INSTALL.md 同步(输出格式 13 → 15 种)
## [2.3.0-r1] - wg-quick / AmneziaWG .conf 导入与导出修复
- 新增 wg-quick / AmneziaWG `.conf` 文本导入:解析 `[Interface]` / `[Peer]` 分段
+1 -1
View File
@@ -10,7 +10,7 @@ PKG_NAME:=luci-app-substore
# 版本约定:每次提交 PKG_RELEASE +1(2.1.2-r1 ~ r9);
# 达到 r10 时 PKG_VERSION 末位 +1(2.1.2 -> 2.1.3),PKG_RELEASE 重置为 1。
PKG_VERSION:=2.3.0
PKG_RELEASE:=5
PKG_RELEASE:=6
LUCI_DEPENDS:=+luci-lua-runtime +luci-compat
+11 -7
View File
@@ -60,9 +60,13 @@ group them, then re-emit them in a format your client can consume.
- SSR (`ssr://`) input is re-emitted losslessly to SSR-capable clients only — Mihomo /
Clash.Meta, Stash, Loon, Egern, Shadowrocket — and dropped for the rest (sing-box,
V2Ray/Xray, Surge family), since SSR is not convertible to/from other protocols
- 13 output formats (all implemented): Plain JSON, Stash, Clash.Meta / Mihomo YAML,
Surfboard, Surge, Surge Mac, Loon, Egern, Shadowrocket, Quantumult X, sing-box,
V2Ray / Xray, V2Ray URI
- 15 output formats (all implemented): Plain JSON, Stash, Clash.Meta / Mihomo YAML,
Clash (original), Surfboard, Surge, Surge Mac, Loon, Egern, Shadowrocket,
Quantumult X, sing-box, V2Ray / Xray, V2Ray URI, WireGuard / AmneziaWG `.conf`
- **Clash (original)**: for Dreamacro Clash / ClashX / Clash for Windows; protocols the
original does not support (vless / hysteria2 / hysteria / tuic / wireguard) are filtered out
- **WireGuard / AmneziaWG `.conf`**: wg-quick single-interface config with `[Interface]` /
`[Peer]` sections and AmneziaWG obfuscation parameters, importable by AmneziaWG clients
**Subscription links**
- Per-subscription random token → public download endpoint
@@ -75,18 +79,18 @@ group them, then re-emit them in a format your client can consume.
## Installation
> The version in the package name must match `PKG_VERSION` / `PKG_RELEASE` in the
> [Makefile](Makefile) (currently `2.3.0-r1`).
> [Makefile](Makefile) (currently `2.3.0-r2`).
opkg (OpenWrt / ImmortalWrt 24.10 and earlier):
```bash
opkg install luci-app-substore-2.3.0-r1.ipk
opkg install luci-app-substore-2.3.0-r2.ipk
```
apk (OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.3.0-r1.apk
apk add --allow-untrusted luci-app-substore-2.3.0-r2.apk
```
Then open LuCI: **Services → Subscriptions**.
@@ -100,7 +104,7 @@ Then open LuCI: **Services → Subscriptions**.
3. **Browse nodes** — filter (group / protocol / keyword), sort, probe latency; tick
checkboxes and hit Delete for batch deletion, edit / delete / regroup in-row, and
Refresh reloads the list.
4. **Export** — pick one of the 13 output formats, or copy the subscription link
4. **Export** — pick one of the 15 output formats, or copy the subscription link
to feed a downstream client (Passwall / OpenClash / …).
## Project layout
+11 -7
View File
@@ -52,9 +52,13 @@
- SSR(`ssr://`)订阅源支持:仅能原样输出到支持它的客户端(Mihomo / Clash.Meta、Stash、
Loon、Egern、Shadowrocket),对其余目标(sing-box、V2Ray/Xray、Surge 家族)丢弃;SSR
不能与 vmess/vless 等其它协议互转(协议不兼容)
- 13 种输出格式(全部实现):Plain JSON、Stash、Clash.Meta / Mihomo YAML、Surfboard、
Surge、Surge Mac、Loon、Egern、Shadowrocket、Quantumult X、sing-box、V2Ray / Xray、
V2Ray URI
- 15 种输出格式(全部实现):Plain JSON、Stash、Clash.Meta / Mihomo YAML、Clash 原版、
Surfboard、Surge、Surge Mac、Loon、Egern、Shadowrocket、Quantumult X、sing-box、
V2Ray / Xray、V2Ray URI、WireGuard / AmneziaWG `.conf`
- **Clash 原版**:面向 Dreamacro Clash / ClashX / Clash for Windows,自动过滤原版不支持的
协议(vless / hysteria2 / hysteria / tuic / wireguard)
- **WireGuard / AmneziaWG `.conf`**:wg-quick 单接口配置,含 `[Interface]` / `[Peer]` 与
AmneziaWG 混淆参数,可直接导入 AmneziaWG 客户端
**订阅链接**
- 每个订阅独立随机 token → 公开下载端点
@@ -66,18 +70,18 @@
## 安装
> 包名中的版本号必须与 [Makefile](Makefile) 的 `PKG_VERSION` / `PKG_RELEASE` 保持一致
> (当前 `2.3.0-r1`)。
> (当前 `2.3.0-r2`)。
opkg(OpenWrt / ImmortalWrt 24.10 及更早):
```bash
opkg install luci-app-substore-2.3.0-r1.ipk
opkg install luci-app-substore-2.3.0-r2.ipk
```
apk(OpenWrt / ImmortalWrt 25.12+):
```bash
apk add --allow-untrusted luci-app-substore-2.3.0-r1.apk
apk add --allow-untrusted luci-app-substore-2.3.0-r2.apk
```
然后在 LuCI 菜单打开:**服务 → 订阅**。
@@ -89,7 +93,7 @@ apk add --allow-untrusted luci-app-substore-2.3.0-r1.apk
2. **更新** —— 下载、解析并过滤节点。
3. **浏览节点** —— 筛选(分组 / 协议 / 关键词)、排序、探测延迟;勾选复选框后「删除」
可批量删除,行内可编辑 / 删除 / 改分组,「刷新」重载列表。
4. **导出** —— 任选 13 种格式之一,或复制订阅链接供下游客户端(Passwall / OpenClash / …)使用。
4. **导出** —— 任选 15 种格式之一,或复制订阅链接供下游客户端(Passwall / OpenClash / …)使用。
## 目录结构
+3 -3
View File
@@ -3,18 +3,18 @@
> NOTE: stage-0 skeleton has not yet been installed/verified on a target device.
> This document will be updated once installation is validated.
>
> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.3.0-r1)。
> 包名中的版本号需与 Makefile 的 `PKG_VERSION` / `PKG_RELEASE` 保持同步(当前 2.3.0-r2)。
## Install (opkg — OpenWrt / ImmortalWrt 24.10 及更早)
```bash
opkg install luci-app-substore-2.3.0-r1.ipk
opkg install luci-app-substore-2.3.0-r2.ipk
```
## Install (apk — OpenWrt / ImmortalWrt 25.12+)
```bash
apk add --allow-untrusted luci-app-substore-2.3.0-r1.apk
apk add --allow-untrusted luci-app-substore-2.3.0-r2.apk
```
Refresh LuCI:
@@ -33,19 +33,7 @@
<div style="display:flex;gap:0.5em;align-items:center;margin:0.5em 0">
<label><%:Format%>:
<select name="fmt">
<option value="clashmeta" <%=fmt=="clashmeta" and "selected" or ""%>>Clash.Meta / Mihomo</option>
<option value="stash" <%=fmt=="stash" and "selected" or ""%>>Stash</option>
<option value="surge" <%=fmt=="surge" and "selected" or ""%>>Surge</option>
<option value="surfboard" <%=fmt=="surfboard" and "selected" or ""%>>Surfboard</option>
<option value="surgemac" <%=fmt=="surgemac" and "selected" or ""%>>SurgeMac</option>
<option value="loon" <%=fmt=="loon" and "selected" or ""%>>Loon</option>
<option value="egern" <%=fmt=="egern" and "selected" or ""%>>Egern</option>
<option value="qx" <%=fmt=="qx" and "selected" or ""%>>Quantumult X</option>
<option value="shadowrocket" <%=fmt=="shadowrocket" and "selected" or ""%>>Shadowrocket</option>
<option value="singbox" <%=fmt=="singbox" and "selected" or ""%>>sing-box</option>
<option value="v2ray" <%=fmt=="v2ray" and "selected" or ""%>>V2Ray</option>
<option value="v2rayuri" <%=fmt=="v2rayuri" and "selected" or ""%>>V2Ray URI</option>
<option value="plain" <%=fmt=="plain" and "selected" or ""%>>Plain JSON</option>
<% for _, fo in ipairs(output.FORMAT_OPTIONS) do %><option value="<%=fo[1]%>" <%=fmt==fo[1] and "selected" or ""%>><%=fo[2]%></option><% end %>
</select>
</label>
<input type="submit" class="cbi-button cbi-button-apply" value="<%:Generate%>"/>
@@ -26,6 +26,7 @@
<%
local core = require("substore.core")
local output = require("substore.output")
local http_env = require("luci.http")
local items = core.list()
local base = luci.dispatcher.build_url("substore", "download")
@@ -81,19 +82,7 @@
<select id="fmt_<%=it.id%>" data-base="<%=luci.util.pcdata(sub_link_base)%>"
onchange="substore_setlink(this, '<%=it.id%>')">
<option value=""><%:Select format%></option>
<option value="clashmeta">Clash.Meta / Mihomo</option>
<option value="stash">Stash</option>
<option value="surge">Surge</option>
<option value="surfboard">Surfboard</option>
<option value="surgemac">SurgeMac</option>
<option value="loon">Loon</option>
<option value="egern">Egern</option>
<option value="qx">Quantumult X</option>
<option value="shadowrocket">Shadowrocket</option>
<option value="singbox">sing-box</option>
<option value="v2ray">V2Ray</option>
<option value="v2rayuri">V2Ray URI</option>
<option value="plain">Plain JSON</option>
<% for _, fo in ipairs(output.FORMAT_OPTIONS) do %><option value="<%=fo[1]%>"><%=fo[2]%></option><% end %>
</select>
</div>
<div id="linkwrap_<%=it.id%>" style="display:none;margin-top:0.25em">
@@ -1,62 +1,23 @@
-- output.lua — 订阅输出统一分发(所有 13 种目标格式)
-- output.lua — 订阅输出统一分发(所有目标格式的唯一注册点)
-- luci-app-substore
local util = require("substore.util")
local node = require("substore.node")
local clash_meta = require("substore.output_clash_meta")
local output_uri = require("substore.output_uri")
local output_singbox = require("substore.output_singbox")
local output_v2ray = require("substore.output_v2ray")
local output_formats = require("substore.output_formats")
local output_wgconf = require("substore.output_wireguard_conf")
local M = {}
local function esc_yaml(s)
s = tostring(s or "")
s = s:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n")
if s:find("[ :#{}[\],&*?|>'\"%@`]", 1, true) or s:match("^[-?]*:") then
return '"' .. s .. '"'
end
return s
end
function M.to_clash_yaml(nodes)
local out = {}
out[#out + 1] = "proxies:"
for _, n in ipairs(nodes) do
local proto = n.proto or "vmess"
local name = esc_yaml(n.name or "")
out[#out + 1] = "- name: " .. name
out[#out + 1] = " type: " .. proto
out[#out + 1] = " server: " .. esc_yaml(n.server or "")
out[#out + 1] = " port: " .. tostring(n.port or 0)
if n.uuid then out[#out + 1] = " uuid: " .. esc_yaml(n.uuid) end
if n.password then out[#out + 1] = " password: " .. esc_yaml(n.password) end
if n.method then out[#out + 1] = " cipher: " .. esc_yaml(n.method) end
if n.net then out[#out + 1] = " network: " .. esc_yaml(n.net) end
if n.security then out[#out + 1] = " tls: " .. tostring(n.security ~= "none") end
if n.sni then out[#out + 1] = " sni: " .. esc_yaml(n.sni) end
end
out[#out + 1] = ""
out[#out + 1] = "proxy-groups:"
out[#out + 1] = "- name: ALL"
out[#out + 1] = " type: select"
out[#out + 1] = " proxies: [REJECT]"
return table.concat(out, "\n")
end
function M.to_json(nodes)
return util.json_encode(nodes)
end
function M.to_base64(nodes)
local yaml = M.to_clash_yaml(nodes)
return util.base64_encode(yaml)
end
-- 目标格式别名映射(兼容 ?target=X 的各类写法)
M.FORMAT_ALIASES = {
clash = "clashmeta",
-- Clash 原版:vless/hysteria2/tuic/wireguard 等原版不支持的协议会被过滤
clash = "clash",
clashclassic = "clash",
clashpremium = "clash",
-- Clash.Meta / Mihomo
yaml = "clashmeta",
clashmeta = "clashmeta",
mihomo = "clashmeta",
@@ -77,6 +38,13 @@ M.FORMAT_ALIASES = {
v2rayuri = "v2rayuri",
v2ray_uri = "v2rayuri",
uri = "v2rayuri",
-- wg-quick / AmneziaWG .conf
wgconf = "wgconf",
wg = "wgconf",
wireguard = "wgconf",
amneziawg = "wgconf",
amnezia = "wgconf",
conf = "wgconf",
plain = "plain",
plainjson = "plain",
json = "plain",
@@ -85,6 +53,7 @@ M.FORMAT_ALIASES = {
-- 目标格式的 HTTP Content-Type
local CONTENT_TYPES = {
clash = "text/plain; charset=utf-8",
clashmeta = "text/plain; charset=utf-8",
stash = "text/plain; charset=utf-8",
surge = "text/plain; charset=utf-8",
@@ -97,12 +66,16 @@ local CONTENT_TYPES = {
singbox = "application/json; charset=utf-8",
v2ray = "application/json; charset=utf-8",
v2rayuri = "text/plain; charset=utf-8",
wgconf = "text/plain; charset=utf-8",
plain = "application/json; charset=utf-8",
}
-- 未指定 target 时的默认格式(保持历史行为:Clash.Meta)
local DEFAULT_FORMAT = "clashmeta"
function M.content_type_for(format)
format = (format or "clash") or "clash"
if type(format) ~= "string" then format = "clash" end
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
if type(format) ~= "string" then format = DEFAULT_FORMAT end
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
return CONTENT_TYPES[norm]
@@ -110,6 +83,7 @@ end
-- 目标格式的下载文件名后缀
local FILENAME_EXT = {
clash = "yaml",
clashmeta = "yaml",
stash = "yaml",
surge = "conf",
@@ -122,26 +96,47 @@ local FILENAME_EXT = {
singbox = "json",
v2ray = "json",
v2rayuri = "txt",
wgconf = "conf",
plain = "json",
}
function M.extension_for(format)
format = (format or "clash") or "clash"
if type(format) ~= "string" then format = "clash" end
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
if type(format) ~= "string" then format = DEFAULT_FORMAT end
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
return FILENAME_EXT[norm] or "txt"
end
-- UI 下拉使用的有序格式列表(单一数据源;模板由此渲染,避免多处硬编码不同步)
M.FORMAT_OPTIONS = {
{ "clashmeta", "Clash.Meta / Mihomo" },
{ "clash", "Clash" },
{ "stash", "Stash" },
{ "surge", "Surge" },
{ "surfboard", "Surfboard" },
{ "surgemac", "SurgeMac" },
{ "loon", "Loon" },
{ "egern", "Egern" },
{ "qx", "Quantumult X" },
{ "shadowrocket", "Shadowrocket" },
{ "singbox", "sing-box" },
{ "v2ray", "V2Ray" },
{ "v2rayuri", "V2Ray URI" },
{ "wgconf", "WireGuard / AmneziaWG .conf" },
{ "plain", "Plain JSON" },
}
-- 统一分发:nodes → 目标格式字符串
function M.generate(nodes, format, options)
format = (format or "clash") or "clash"
if type(format) ~= "string" then format = "clash" end
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
if type(format) ~= "string" then format = DEFAULT_FORMAT end
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
if not norm then return nil, "unsupported format: " .. tostring(format) end
if norm == "clashmeta" then return clash_meta.generate(nodes, options) end
if norm == "clash" then return output_formats.to_clash(nodes, options) end
if norm == "stash" then return output_formats.to_stash(nodes, options) end
if norm == "surge" then return output_formats.to_surge(nodes, options) end
if norm == "surfboard" then return output_formats.to_surfboard(nodes, options) end
@@ -153,6 +148,7 @@ function M.generate(nodes, format, options)
if norm == "singbox" then return output_singbox.generate(nodes) end
if norm == "v2ray" then return output_v2ray.generate(nodes) end
if norm == "v2rayuri" then return output_uri.to_v2ray_uri(nodes) end
if norm == "wgconf" then return output_wgconf.generate(nodes, options) end
if norm == "plain" then return output_formats.to_plain(nodes) end
return nil, "unsupported format: " .. tostring(format)
@@ -147,6 +147,22 @@ function M.to_stash(nodes, options)
return clash_meta.generate(nodes, options)
end
-- Clash 原版(Dreamacro Clash / ClashX / Clash for Windows)不支持的协议类型。
-- 只排除"确定不支持"的,不做白名单,避免误丢原版其实支持的类型。
local CLASH_LEGACY_UNSUPPORTED = {
vless = true, hysteria2 = true, hysteria = true, tuic = true, wireguard = true,
}
-- Clash 原版:过滤掉原版不认识的协议后,复用 Clash.Meta 的 YAML 生成
function M.to_clash(nodes, options)
local kept = {}
for _, n in ipairs(nodes or {}) do
local p = props(n)
if not CLASH_LEGACY_UNSUPPORTED[p] then kept[#kept + 1] = n end
end
return clash_meta.generate(kept, options)
end
-- Quantumult X
function M.to_qx(nodes, options)
options = options or {}
@@ -210,6 +226,7 @@ function M.generate(nodes, format, options)
if format == "egern" then return M.to_egern(nodes, options) end
if format == "qx" then return M.to_qx(nodes, options) end
if format == "stash" then return M.to_stash(nodes, options) end
if format == "clash" then return M.to_clash(nodes, options) end
if format == "plain" then return M.to_plain(nodes) end
return nil, "unsupported format: " .. tostring(format)
end
@@ -0,0 +1,107 @@
-- output_wireguard_conf.lua — wg-quick / AmneziaWG .conf 输出(纯 Lua)
-- luci-app-substore
-- 与 parser.parse_wireguard_conf 互为逆操作:导入解析 [Interface] / [Peer],此处按同格式写回。
local M = {}
-- 仅 wireguard 节点可用 .conf 表达;其余协议无法落到单行/单段,直接丢弃
-- (与 output_formats.surge_config 丢弃 wireguard 的处理对称)
local function is_wireguard(n)
local p = (n.proto or ""):lower()
return p == "wireguard" or p == "wg"
end
-- amnezia-wg-option 键 → .conf 键:首字母大写(jc → Jc,jmin → Jmin,itime → Itime)
local function conf_key(k)
return (k:gsub("^%l", string.upper))
end
-- 值可能是标量或数组,统一转为 "a, b, c"
local function join_list(v)
if v == nil then return nil end
if type(v) == "table" then
local t = {}
for _, x in ipairs(v) do
if x ~= nil and tostring(x) ~= "" then t[#t + 1] = tostring(x) end
end
if #t == 0 then return nil end
return table.concat(t, ", ")
end
if tostring(v) == "" then return nil end
return tostring(v)
end
-- Endpoint:IPv6 主机需加方括号(wg-quick 语法)
local function endpoint_of(n)
local host = n.server
local port = n.port
if not host or host == "" or not port then return nil end
if host:find(":", 1, true) and host:sub(1, 1) ~= "[" then
host = "[" .. host .. "]"
end
return host .. ":" .. tostring(port)
end
-- 单个节点 → .conf 文本行数组
local function build_section(n)
local out = {}
local function put(k, v)
if v ~= nil and v ~= "" then out[#out + 1] = k .. " = " .. v end
end
out[#out + 1] = "[Interface]"
put("PrivateKey", n["private-key"] or n.private_key)
-- Address 合并 IPv4 / IPv6
local addr = {}
if n.ip then addr[#addr + 1] = n.ip end
if n.ipv6 then addr[#addr + 1] = n.ipv6 end
put("Address", join_list(addr))
put("ListenPort", n["listen-port"] or n.listen_port)
put("MTU", n.mtu)
put("DNS", join_list(n.dns))
-- AmneziaWG 参数:按 .conf 键名排序输出,保证可 diff
local opt = n["amnezia-wg-option"]
if type(opt) == "table" then
local keys = {}
for k in pairs(opt) do keys[#keys + 1] = k end
table.sort(keys)
for _, k in ipairs(keys) do
put(conf_key(k), tostring(opt[k]))
end
end
out[#out + 1] = ""
out[#out + 1] = "[Peer]"
put("PublicKey", n["public-key"] or n.public_key or n["peer-public-key"] or n.peer_public_key)
put("PresharedKey", n["pre-shared-key"] or n.pre_shared_key or n["preshared-key"] or n.preshared_key)
put("AllowedIPs", join_list(n["allowed-ips"]))
put("Endpoint", endpoint_of(n))
put("PersistentKeepalive", n["persistent-keepalive"])
-- 说明:不输出 Reserved —— 它不是 wg-quick 标准键,写进 .conf 可能被客户端拒绝。
-- reserved 仍保留在 clash.meta / sing-box / URI 输出中。
return out
end
-- nodes → wg-quick .conf 文本
-- 注意:wg-quick / AmneziaWG 客户端一个文件导入一条隧道。单节点时输出即为标准单接口
-- .conf;多节点时各段以 "# <名称>" 注释分隔,便于查看与手工拆分。
function M.generate(nodes, options)
local blocks = {}
for _, n in ipairs(nodes or {}) do
if type(n) == "table" and is_wireguard(n) then
local sec = build_section(n)
local named = { "# " .. (n.name or ((n.server or "") .. ":" .. tostring(n.port or ""))) }
for _, line in ipairs(sec) do named[#named + 1] = line end
blocks[#blocks + 1] = table.concat(named, "\n")
end
end
if #blocks == 0 then
-- 明确报错优于返回空文件:用户能知道是"没有 WireGuard 节点"而非订阅坏了
return nil, "没有可导出的 WireGuard 节点"
end
return table.concat(blocks, "\n\n") .. "\n"
end
return M
+7 -3
View File
@@ -40,12 +40,16 @@ check("token idempotent", core.ensure_token(id) == token)
local nodes = {
{ proto = "vmess", name = "NodeA", server = "1.1.1.1", port = 443, uuid = "u1", net = "tcp" },
{ proto = "shadowsocks", name = "NodeB", server = "2.2.2.2", port = 8388, method = "aes-256-gcm", password = "p1" },
{ proto = "wireguard", name = "NodeC", server = "3.3.3.3", port = 51820, ["private-key"] = "K", ["public-key"] = "P", ip = "10.0.0.1/32" },
}
check("write nodes", core.write_nodes(id, nodes) == true)
-- 各格式生成
-- 各格式生成(列表与 output.FORMAT_OPTIONS 保持一致)
local output_mod = require("substore.output")
local targets = {}
for _, fo in ipairs(output_mod.FORMAT_OPTIONS) do targets[#targets + 1] = fo[1] end
local ok = true
for _, target in ipairs({ "ClashMeta", "singbox", "v2ray", "Surge", "QX", "Shadowrocket", "V2RayURI", "Plain", "Stash", "Loon", "Egern", "Surfboard", "SurgeMac" }) do
for _, target in ipairs(targets) do
local content, ct, filename, err = core.generate_link(token, target)
if type(content) == "string" and content ~= "" and ct and filename then
-- ok
@@ -54,7 +58,7 @@ for _, target in ipairs({ "ClashMeta", "singbox", "v2ray", "Surge", "QX", "Shado
print(" fail:", target, tostring(err))
end
end
check("all 13 targets generate", ok)
check("all " .. #targets .. " targets generate", ok)
local yaml, ct, filename = core.generate_link(token, "ClashMeta")
check("clashmeta has proxies", yaml and yaml:find("proxies:") ~= nil)
@@ -33,7 +33,7 @@ local nodes = {
check("module exists", output ~= nil)
check("generate exists", type(output.generate) == "function")
-- 13 种目标格式均返回字符串
-- 各目标格式均返回字符串
local formats = {
"clash", "clashmeta", "mihomo", "stash",
"surge", "surfboard", "surgemac", "loon", "egern",
@@ -83,7 +83,10 @@ local pj = util.json_decode(plain)
check("plain is json array", type(pj) == "table" and pj[1] ~= nil and pj[1].proto == "vmess")
-- 别名映射
check("alias clashmeta", output.generate(nodes, "clashmeta") == output.generate(nodes, "clash"))
-- 注意:clash 自 2.3.0-r2 起指"Clash 原版"(会过滤 vless/hysteria2/tuic/wireguard),
-- 不再是 clashmeta 的别名;clashmeta 的别名是 yaml / mihomo
check("alias yaml -> clashmeta", output.generate(nodes, "clashmeta") == output.generate(nodes, "yaml"))
check("alias mihomo -> clashmeta", output.generate(nodes, "clashmeta") == output.generate(nodes, "mihomo"))
check("alias sing_box", output.generate(nodes, "sing_box") == output.generate(nodes, "singbox"))
-- 未知格式报错
@@ -0,0 +1,176 @@
-- output_new_formats_test.lua — 新增输出格式(Clash 原版 / wg-quick .conf)与格式注册表一致性
-- 用法:lua5.1 tests/output_new_formats_test.lua
package.path = "./root/usr/share/?.lua;" .. package.path
local output = require("substore.output")
local parser = require("substore.parser")
local util = require("substore.util")
local passed, failed = 0, 0
local function check(name, cond)
if cond then
passed = passed + 1
print("PASS " .. name)
else
failed = failed + 1
print("FAIL " .. name)
end
end
-- ---------- 格式注册表一致性(防止只加了一半) ----------
-- 每个 UI 选项都必须能被 generate 解析、并有 content-type 与扩展名
local all_resolve = true
for _, fo in ipairs(output.FORMAT_OPTIONS) do
local v = fo[1]
local norm = output.FORMAT_ALIASES[v]
if not norm then
all_resolve = false
print(" no alias:", v)
end
if not output.content_type_for(v) then
all_resolve = false
print(" no content-type:", v)
end
if not output.extension_for(v) then
all_resolve = false
print(" no extension:", v)
end
end
check("every FORMAT_OPTIONS entry resolves", all_resolve)
check("FORMAT_OPTIONS has 15 entries", #output.FORMAT_OPTIONS == 15)
-- 每个规范名都必须有分发分支。
-- 用空节点表探测:未注册的格式会返回 "unsupported format: ...",
-- 而注册过的格式可能返回空串或业务错误(如 wgconf 无 WireGuard 节点),都算已分发。
local dispatch_ok = true
for _, fo in ipairs(output.FORMAT_OPTIONS) do
local content, err = output.generate({}, fo[1])
if type(content) ~= "string" and tostring(err):find("unsupported format", 1, true) then
dispatch_ok = false
print(" dispatch fail:", fo[1], tostring(err))
end
end
check("every FORMAT_OPTIONS entry dispatches", dispatch_ok)
-- 旧别名仍可用
check("alias mihomo -> clashmeta", output.FORMAT_ALIASES["mihomo"] == "clashmeta")
check("alias yaml -> clashmeta", output.FORMAT_ALIASES["yaml"] == "clashmeta")
check("alias clash -> clash (原版)", output.FORMAT_ALIASES["clash"] == "clash")
check("alias amneziawg -> wgconf", output.FORMAT_ALIASES["amneziawg"] == "wgconf")
check("alias wireguard -> wgconf", output.FORMAT_ALIASES["wireguard"] == "wgconf")
-- 默认格式仍是 Clash.Meta(未指定 target 时行为不变)
check("default content-type is clashmeta", output.content_type_for(nil) == "text/plain; charset=utf-8")
local def = output.generate({ { proto = "vless", name = "D", server = "1.1.1.1", port = 443, uuid = "u" } })
check("default keeps vless (clashmeta)", def:find("type: vless") ~= nil)
-- 死代码已移除
check("to_clash_yaml removed", output.to_clash_yaml == nil)
check("to_json removed", output.to_json == nil)
check("to_base64 removed", output.to_base64 == nil)
-- ---------- Clash 原版:过滤原版不支持的协议 ----------
local mixed = {
{ proto = "vmess", name = "V", server = "1.1.1.1", port = 443, uuid = "u1", net = "tcp" },
{ proto = "shadowsocks", name = "S", server = "2.2.2.2", port = 8388, method = "aes-256-gcm", password = "p" },
{ proto = "trojan", name = "T", server = "3.3.3.3", port = 443, password = "p" },
{ proto = "ssr", name = "R", server = "4.4.4.4", port = 443, method = "aes-128-cfb", password = "p", protocol = "origin", obfs = "plain" },
{ proto = "vless", name = "VL", server = "5.5.5.5", port = 443, uuid = "u2" },
{ proto = "hysteria2", name = "H2", server = "6.6.6.6", port = 443, password = "p" },
{ proto = "tuic", name = "TU", server = "7.7.7.7", port = 443, uuid = "u3", password = "p" },
{ proto = "wireguard", name = "WG", server = "8.8.8.8", port = 51820, ["private-key"] = "K", ["public-key"] = "P", ip = "10.0.0.1/32" },
}
local cl = output.generate(mixed, "clash")
check("clash legacy keeps vmess", cl:find("- name: V\n") ~= nil)
check("clash legacy keeps shadowsocks", cl:find("- name: S\n") ~= nil)
check("clash legacy keeps trojan", cl:find("- name: T\n") ~= nil)
check("clash legacy keeps ssr", cl:find("- name: R\n") ~= nil)
check("clash legacy drops vless", cl:find("- name: VL\n") == nil)
check("clash legacy drops hysteria2", cl:find("- name: H2\n") == nil)
check("clash legacy drops tuic", cl:find("- name: TU\n") == nil)
check("clash legacy drops wireguard", cl:find("- name: WG\n") == nil)
check("clash legacy ss type is ss", cl:find("type: ss") ~= nil)
-- Clash.Meta 保留全部
local cm = output.generate(mixed, "clashmeta")
check("clashmeta keeps vless", cm:find("- name: VL\n") ~= nil)
check("clashmeta keeps wireguard", cm:find("- name: WG\n") ~= nil)
-- ---------- wg-quick / AmneziaWG .conf 输出 ----------
local wg_nodes = {
{ proto = "vmess", name = "NOTWG", server = "1.1.1.1", port = 443, uuid = "u" },
{ proto = "wireguard", name = "WG HK 01", server = "wg.example.com", port = 51820,
["private-key"] = "PRIV", ["public-key"] = "PUB", ["pre-shared-key"] = "PSK",
ip = "10.7.0.2/32", ipv6 = "fd00:7::2/128",
["allowed-ips"] = { "0.0.0.0/0", "::/0" }, ["persistent-keepalive"] = 25,
["listen-port"] = 51820, mtu = 1420, dns = { "1.1.1.1", "8.8.8.8" },
["amnezia-wg-option"] = { jc = 5, jmin = 50, jmax = 1000, s1 = 86, s2 = 574, h1 = 1234567 } },
}
local wc = output.generate(wg_nodes, "wgconf")
check("wgconf has Interface", wc:find("%[Interface%]") ~= nil)
check("wgconf has Peer", wc:find("%[Peer%]") ~= nil)
check("wgconf drops non-wireguard", wc:find("NOTWG") == nil)
check("wgconf comment header", wc:find("# WG HK 01") ~= nil)
check("wgconf PrivateKey", wc:find("PrivateKey = PRIV") ~= nil)
check("wgconf Address joined", wc:find("Address = 10%.7%.0%.2/32, fd00:7::2/128") ~= nil)
check("wgconf ListenPort", wc:find("ListenPort = 51820") ~= nil)
check("wgconf MTU", wc:find("MTU = 1420") ~= nil)
check("wgconf DNS joined", wc:find("DNS = 1%.1%.1%.1, 8%.8%.8%.8") ~= nil)
check("wgconf PublicKey", wc:find("PublicKey = PUB") ~= nil)
check("wgconf PresharedKey", wc:find("PresharedKey = PSK") ~= nil)
check("wgconf AllowedIPs joined", wc:find("AllowedIPs = 0%.0%.0%.0/0, ::/0") ~= nil)
check("wgconf Endpoint", wc:find("Endpoint = wg%.example%.com:51820") ~= nil)
check("wgconf PersistentKeepalive", wc:find("PersistentKeepalive = 25") ~= nil)
-- AmneziaWG 参数:.conf 键名首字母大写,且排序稳定
check("wgconf awg Jc", wc:find("Jc = 5") ~= nil)
check("wgconf awg Jmin", wc:find("Jmin = 50") ~= nil)
check("wgconf awg Jmax", wc:find("Jmax = 1000") ~= nil)
check("wgconf awg S1", wc:find("S1 = 86") ~= nil)
check("wgconf awg H1", wc:find("H1 = 1234567") ~= nil)
local i_h1, i_jc, i_s1 = wc:find("\nH1 = "), wc:find("\nJc = "), wc:find("\nS1 = ")
check("wgconf awg sorted", i_h1 and i_jc and i_s1 and i_h1 < i_jc and i_jc < i_s1)
check("wgconf deterministic", output.generate(wg_nodes, "wgconf") == wc)
-- 无 wireguard 节点时明确报错(而非返回空文件)
local none, nerr = output.generate({ { proto = "vmess", name = "x", server = "1.1.1.1", port = 1 } }, "wgconf")
check("wgconf errors when no wireguard", none == nil and type(nerr) == "string" and nerr:find("WireGuard") ~= nil)
-- IPv6 Endpoint 需加方括号
local v6 = output.generate({ { proto = "wireguard", name = "v6", server = "2001:db8::1", port = 51821, ["private-key"] = "K", ["public-key"] = "P", ip = "10.9.0.2/32" } }, "wgconf")
check("wgconf ipv6 endpoint bracketed", v6:find("Endpoint = %[2001:db8::1%]:51821") ~= nil)
-- ---------- 往返:wgconf 导出 → 重新导入 ----------
local rt = parser.parse(wc)
check("roundtrip parses", rt ~= nil and #rt.nodes == 1)
check("roundtrip format", rt and rt.format == "wireguard-conf")
local r = rt and rt.nodes[1] or {}
check("roundtrip proto", r.proto == "wireguard")
check("roundtrip server", r.server == "wg.example.com")
check("roundtrip port", r.port == 51820)
check("roundtrip private-key", r["private-key"] == "PRIV")
check("roundtrip public-key", r["public-key"] == "PUB")
check("roundtrip pre-shared-key", r["pre-shared-key"] == "PSK")
check("roundtrip ip", r.ip == "10.7.0.2/32")
check("roundtrip ipv6", r.ipv6 == "fd00:7::2/128")
check("roundtrip allowed-ips", type(r["allowed-ips"]) == "table" and #r["allowed-ips"] == 2 and r["allowed-ips"][2] == "::/0")
check("roundtrip persistent-keepalive", r["persistent-keepalive"] == 25)
check("roundtrip listen-port", r["listen-port"] == 51820)
check("roundtrip mtu", r.mtu == 1420)
check("roundtrip dns", type(r.dns) == "table" and #r.dns == 2)
local ro = r["amnezia-wg-option"]
check("roundtrip awg jc", ro and ro.jc == 5)
check("roundtrip awg h1", ro and ro.h1 == 1234567)
check("roundtrip awg s1", ro and ro.s1 == 86)
-- 已知有损项:reserved 不在 wg-quick 标准键内,导出时不写出
check("roundtrip reserved dropped (documented)", r.reserved == nil)
-- ---------- 结果 ----------
print(string.format("\n%d passed, %d failed", passed, failed))
os.exit(failed == 0 and 0 or 1)
+12 -5
View File
@@ -9,7 +9,7 @@ LUCI_DEPENDS:=+tailscale +jshn +curl +jq +flock
LUCI_PKGARCH:=all
PKG_VERSION:=1.2.17
PKG_RELEASE:=3
PKG_RELEASE:=4
# Keep UCI configs as ordinary package data. The pre-install hook snapshots an
# existing configuration before extraction; the UCI-defaults script restores it
@@ -43,7 +43,8 @@ disable_legacy_hotplug() {
disable_legacy_hotplug || exit 1
[ -f /etc/config/tailscale ] || exit 0
# 若系统内无任何相关配置文件,说明是全新安装,直接退出让包管理器正常解压释放默认配置
[ -f /etc/config/tailscale ] || [ -f /etc/config/tailscale_openclash ] || [ -f /etc/config/tailscale_policy_routing ] || exit 0
[ -f "$${state_dir}/.complete" ] && exit 0
clear_state_dir() {
@@ -55,20 +56,26 @@ clear_state_dir() {
clear_state_dir "$${state_dir}" || exit 1
clear_state_dir "$${state_pending}" || exit 1
umask 077
mkdir "$${state_pending}" || exit 1
chmod 700 "$${state_pending}" || exit 1
cp /etc/config/tailscale "$${state_pending}/tailscale" || exit 1
if [ -f /etc/config/tailscale ]; then
cp /etc/config/tailscale "$${state_pending}/tailscale" || exit 1
fi
if [ -f /etc/config/tailscale_openclash ]; then
cp /etc/config/tailscale_openclash "$${state_pending}/tailscale_openclash" || exit 1
else
: >"$${state_pending}/tailscale_openclash.absent" || exit 1
fi
if [ -f /etc/config/tailscale_policy_routing ]; then
cp /etc/config/tailscale_policy_routing "$${state_pending}/tailscale_policy_routing" || exit 1
fi
: >"$${state_pending}/.complete" || exit 1
mv "$${state_pending}" "$${state_dir}" || exit 1
exit 0
endef
@@ -12,7 +12,7 @@ LUCI_INDEX_CACHE="${LUCI_INDEX_CACHE:-/tmp/luci-indexcache}"
cleanup_upgrade_state() {
rm -f "$UPGRADE_STATE_DIR/tailscale" "$UPGRADE_STATE_DIR/tailscale_openclash" \
"$UPGRADE_STATE_DIR/tailscale_openclash.absent" "$UPGRADE_STATE_DIR/tailscale_policy_routing" \
"$UPGRADE_STATE_DIR/tailscale_policy_routing" \
"$UPGRADE_STATE_DIR/.complete"
rmdir "$UPGRADE_STATE_DIR" 2>/dev/null || true
}
@@ -47,20 +47,18 @@ cleanup_legacy_adguard_dns_service() {
fi
cp -p "$LEGACY_ADGUARD_DNS_INIT" "$backup_dir/init" || return 1
for link in "$LEGACY_ADGUARD_DNS_RC_DIR"/*tailscale-adguard-dns; do
[ -e "$link" ] || [ -L "$link" ] || continue
rm -f "$link" || return 1
done
mv "$LEGACY_ADGUARD_DNS_INIT" "$backup_dir/tailscale-adguard-dns.disabled" || return 1
}
if [ -f "$UPGRADE_STATE_DIR/.complete" ]; then
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale" /etc/config/tailscale || exit 1
if [ -f "$UPGRADE_STATE_DIR/tailscale_openclash.absent" ]; then
rm -f /etc/config/tailscale_openclash
else
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale_openclash" /etc/config/tailscale_openclash || exit 1
fi
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale_openclash" /etc/config/tailscale_openclash || exit 1
restore_upgrade_config "$UPGRADE_STATE_DIR/tailscale_policy_routing" /etc/config/tailscale_policy_routing || exit 1
cleanup_upgrade_state
elif [ -d "$UPGRADE_STATE_DIR" ]; then