mirror of
https://github.com/SagerNet/sing-box.git
synced 2024-11-22 08:31:30 +00:00
clash api: download clash-dashboard if external-ui directory is empty
This commit is contained in:
parent
e168de79c7
commit
750f87bb0a
114
common/badversion/version.go
Normal file
114
common/badversion/version.go
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
package badversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
F "github.com/sagernet/sing/common/format"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Version struct {
|
||||||
|
Major int
|
||||||
|
Minor int
|
||||||
|
Patch int
|
||||||
|
PreReleaseIdentifier string
|
||||||
|
PreReleaseVersion int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v Version) After(anotherVersion Version) bool {
|
||||||
|
if v.Major > anotherVersion.Major {
|
||||||
|
return true
|
||||||
|
} else if v.Major < anotherVersion.Major {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v.Minor > anotherVersion.Minor {
|
||||||
|
return true
|
||||||
|
} else if v.Minor < anotherVersion.Minor {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v.Patch > anotherVersion.Patch {
|
||||||
|
return true
|
||||||
|
} else if v.Patch < anotherVersion.Patch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v.PreReleaseIdentifier == "" && anotherVersion.PreReleaseIdentifier != "" {
|
||||||
|
return true
|
||||||
|
} else if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v.PreReleaseIdentifier != "" && anotherVersion.PreReleaseIdentifier != "" {
|
||||||
|
if v.PreReleaseIdentifier == "beta" && anotherVersion.PreReleaseIdentifier == "alpha" {
|
||||||
|
return true
|
||||||
|
} else if v.PreReleaseIdentifier == "alpha" && anotherVersion.PreReleaseIdentifier == "beta" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v.PreReleaseVersion > anotherVersion.PreReleaseVersion {
|
||||||
|
return true
|
||||||
|
} else if v.PreReleaseVersion < anotherVersion.PreReleaseVersion {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v Version) String() string {
|
||||||
|
version := F.ToString(v.Major, ".", v.Minor, ".", v.Patch)
|
||||||
|
if v.PreReleaseIdentifier != "" {
|
||||||
|
version = F.ToString(version, "-", v.PreReleaseIdentifier, ".", v.PreReleaseVersion)
|
||||||
|
}
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v Version) BadString() string {
|
||||||
|
version := F.ToString(v.Major, ".", v.Minor)
|
||||||
|
if v.Patch > 0 {
|
||||||
|
version = F.ToString(version, ".", v.Patch)
|
||||||
|
}
|
||||||
|
if v.PreReleaseIdentifier != "" {
|
||||||
|
version = F.ToString(version, "-", v.PreReleaseIdentifier)
|
||||||
|
if v.PreReleaseVersion > 0 {
|
||||||
|
version = F.ToString(version, v.PreReleaseVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(versionName string) (version Version) {
|
||||||
|
if strings.HasPrefix(versionName, "v") {
|
||||||
|
versionName = versionName[1:]
|
||||||
|
}
|
||||||
|
if strings.Contains(versionName, "-") {
|
||||||
|
parts := strings.Split(versionName, "-")
|
||||||
|
versionName = parts[0]
|
||||||
|
identifier := parts[1]
|
||||||
|
if strings.Contains(identifier, ".") {
|
||||||
|
identifierParts := strings.Split(identifier, ".")
|
||||||
|
version.PreReleaseIdentifier = identifierParts[0]
|
||||||
|
if len(identifierParts) >= 2 {
|
||||||
|
version.PreReleaseVersion, _ = strconv.Atoi(identifierParts[1])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if strings.HasPrefix(identifier, "alpha") {
|
||||||
|
version.PreReleaseIdentifier = "alpha"
|
||||||
|
version.PreReleaseVersion, _ = strconv.Atoi(identifier[5:])
|
||||||
|
} else if strings.HasPrefix(identifier, "beta") {
|
||||||
|
version.PreReleaseIdentifier = "beta"
|
||||||
|
version.PreReleaseVersion, _ = strconv.Atoi(identifier[4:])
|
||||||
|
} else {
|
||||||
|
version.PreReleaseIdentifier = identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
versionElements := strings.Split(versionName, ".")
|
||||||
|
versionLen := len(versionElements)
|
||||||
|
if versionLen >= 1 {
|
||||||
|
version.Major, _ = strconv.Atoi(versionElements[0])
|
||||||
|
}
|
||||||
|
if versionLen >= 2 {
|
||||||
|
version.Minor, _ = strconv.Atoi(versionElements[1])
|
||||||
|
}
|
||||||
|
if versionLen >= 3 {
|
||||||
|
version.Patch, _ = strconv.Atoi(versionElements[2])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
17
common/badversion/version_json.go
Normal file
17
common/badversion/version_json.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package badversion
|
||||||
|
|
||||||
|
import "github.com/sagernet/sing-box/common/json"
|
||||||
|
|
||||||
|
func (v Version) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(v.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Version) UnmarshalJSON(data []byte) error {
|
||||||
|
var version string
|
||||||
|
err := json.Unmarshal(data, &version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*v = Parse(version)
|
||||||
|
return nil
|
||||||
|
}
|
18
common/badversion/version_test.go
Normal file
18
common/badversion/version_test.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
package badversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompareVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.Equal(t, "1.3.0-beta.1", Parse("v1.3.0-beta1").String())
|
||||||
|
require.Equal(t, "1.3-beta1", Parse("v1.3.0-beta.1").BadString())
|
||||||
|
require.True(t, Parse("1.3.0").After(Parse("1.3-beta1")))
|
||||||
|
require.True(t, Parse("1.3.0").After(Parse("1.3.0-beta1")))
|
||||||
|
require.True(t, Parse("1.3.0-beta1").After(Parse("1.3.0-alpha1")))
|
||||||
|
require.True(t, Parse("1.3.1").After(Parse("1.3.0")))
|
||||||
|
require.True(t, Parse("1.4").After(Parse("1.3")))
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ const dirName = "sing-box"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
basePath string
|
basePath string
|
||||||
|
tempPath string
|
||||||
resourcePaths []string
|
resourcePaths []string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -22,10 +23,21 @@ func BasePath(name string) string {
|
||||||
return filepath.Join(basePath, name)
|
return filepath.Join(basePath, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateTemp(pattern string) (*os.File, error) {
|
||||||
|
if tempPath == "" {
|
||||||
|
tempPath = os.TempDir()
|
||||||
|
}
|
||||||
|
return os.CreateTemp(tempPath, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
func SetBasePath(path string) {
|
func SetBasePath(path string) {
|
||||||
basePath = path
|
basePath = path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetTempPath(path string) {
|
||||||
|
tempPath = path
|
||||||
|
}
|
||||||
|
|
||||||
func FindPath(name string) (string, bool) {
|
func FindPath(name string) (string, bool) {
|
||||||
name = os.ExpandEnv(name)
|
name = os.ExpandEnv(name)
|
||||||
if rw.FileExists(name) {
|
if rw.FileExists(name) {
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
"clash_api": {
|
"clash_api": {
|
||||||
"external_controller": "127.0.0.1:9090",
|
"external_controller": "127.0.0.1:9090",
|
||||||
"external_ui": "folder",
|
"external_ui": "folder",
|
||||||
|
"external_ui_download_url": "",
|
||||||
|
"external_ui_download_detour": "",
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"default_mode": "rule",
|
"default_mode": "rule",
|
||||||
"store_selected": false,
|
"store_selected": false,
|
||||||
|
@ -53,6 +55,18 @@ A relative path to the configuration directory or an absolute path to a
|
||||||
directory in which you put some static web resource. sing-box will then
|
directory in which you put some static web resource. sing-box will then
|
||||||
serve it at `http://{{external-controller}}/ui`.
|
serve it at `http://{{external-controller}}/ui`.
|
||||||
|
|
||||||
|
#### external_ui_download_url
|
||||||
|
|
||||||
|
ZIP download URL for the external UI, will be used if the specified `external_ui` directory is empty.
|
||||||
|
|
||||||
|
`https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip` will be used if empty.
|
||||||
|
|
||||||
|
#### external_ui_download_detour
|
||||||
|
|
||||||
|
The tag of the outbound to download the external UI.
|
||||||
|
|
||||||
|
Default outbound will be used if empty.
|
||||||
|
|
||||||
#### secret
|
#### secret
|
||||||
|
|
||||||
Secret for the RESTful API (optional)
|
Secret for the RESTful API (optional)
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
"clash_api": {
|
"clash_api": {
|
||||||
"external_controller": "127.0.0.1:9090",
|
"external_controller": "127.0.0.1:9090",
|
||||||
"external_ui": "folder",
|
"external_ui": "folder",
|
||||||
|
"external_ui_download_url": "",
|
||||||
|
"external_ui_download_detour": "",
|
||||||
"secret": "",
|
"secret": "",
|
||||||
"default_mode": "rule",
|
"default_mode": "rule",
|
||||||
"store_selected": false,
|
"store_selected": false,
|
||||||
|
@ -51,6 +53,18 @@ RESTful web API 监听地址。如果为空,则禁用 Clash API。
|
||||||
|
|
||||||
到静态网页资源目录的相对路径或绝对路径。sing-box 会在 `http://{{external-controller}}/ui` 下提供它。
|
到静态网页资源目录的相对路径或绝对路径。sing-box 会在 `http://{{external-controller}}/ui` 下提供它。
|
||||||
|
|
||||||
|
#### external_ui_download_url
|
||||||
|
|
||||||
|
静态网页资源的 ZIP 下载 URL,如果指定的 `external_ui` 目录为空,将使用。
|
||||||
|
|
||||||
|
默认使用 `https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip`。
|
||||||
|
|
||||||
|
#### external_ui_download_detour
|
||||||
|
|
||||||
|
用于下载静态网页资源的出站的标签。
|
||||||
|
|
||||||
|
如果为空,将使用默认出站。
|
||||||
|
|
||||||
#### secret
|
#### secret
|
||||||
|
|
||||||
RESTful API 的密钥(可选)
|
RESTful API 的密钥(可选)
|
||||||
|
|
|
@ -47,6 +47,10 @@ type Server struct {
|
||||||
storeFakeIP bool
|
storeFakeIP bool
|
||||||
cacheFilePath string
|
cacheFilePath string
|
||||||
cacheFile adapter.ClashCacheFile
|
cacheFile adapter.ClashCacheFile
|
||||||
|
|
||||||
|
externalUI string
|
||||||
|
externalUIDownloadURL string
|
||||||
|
externalUIDownloadDetour string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) {
|
func NewServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) {
|
||||||
|
@ -64,6 +68,8 @@ func NewServer(router adapter.Router, logFactory log.ObservableFactory, options
|
||||||
mode: strings.ToLower(options.DefaultMode),
|
mode: strings.ToLower(options.DefaultMode),
|
||||||
storeSelected: options.StoreSelected,
|
storeSelected: options.StoreSelected,
|
||||||
storeFakeIP: options.StoreFakeIP,
|
storeFakeIP: options.StoreFakeIP,
|
||||||
|
externalUIDownloadURL: options.ExternalUIDownloadURL,
|
||||||
|
externalUIDownloadDetour: options.ExternalUIDownloadDetour,
|
||||||
}
|
}
|
||||||
if server.mode == "" {
|
if server.mode == "" {
|
||||||
server.mode = "rule"
|
server.mode = "rule"
|
||||||
|
@ -105,8 +111,9 @@ func NewServer(router adapter.Router, logFactory log.ObservableFactory, options
|
||||||
r.Mount("/dns", dnsRouter(router))
|
r.Mount("/dns", dnsRouter(router))
|
||||||
})
|
})
|
||||||
if options.ExternalUI != "" {
|
if options.ExternalUI != "" {
|
||||||
|
server.externalUI = C.BasePath(os.ExpandEnv(options.ExternalUI))
|
||||||
chiRouter.Group(func(r chi.Router) {
|
chiRouter.Group(func(r chi.Router) {
|
||||||
fs := http.StripPrefix("/ui", http.FileServer(http.Dir(C.BasePath(os.ExpandEnv(options.ExternalUI)))))
|
fs := http.StripPrefix("/ui", http.FileServer(http.Dir(server.externalUI)))
|
||||||
r.Get("/ui", http.RedirectHandler("/ui/", http.StatusTemporaryRedirect).ServeHTTP)
|
r.Get("/ui", http.RedirectHandler("/ui/", http.StatusTemporaryRedirect).ServeHTTP)
|
||||||
r.Get("/ui/*", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/ui/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
fs.ServeHTTP(w, r)
|
fs.ServeHTTP(w, r)
|
||||||
|
@ -128,6 +135,7 @@ func (s *Server) PreStart() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Start() error {
|
func (s *Server) Start() error {
|
||||||
|
s.checkAndDownloadExternalUI()
|
||||||
listener, err := net.Listen("tcp", s.httpServer.Addr)
|
listener, err := net.Listen("tcp", s.httpServer.Addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return E.Cause(err, "external controller listen error")
|
return E.Cause(err, "external controller listen error")
|
||||||
|
|
164
experimental/clashapi/server_resources.go
Normal file
164
experimental/clashapi/server_resources.go
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
package clashapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing/common"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) checkAndDownloadExternalUI() {
|
||||||
|
if s.externalUI == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(s.externalUI)
|
||||||
|
if err != nil {
|
||||||
|
os.MkdirAll(s.externalUI, 0o755)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
err = s.downloadExternalUI()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("download external ui error: ", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) downloadExternalUI() error {
|
||||||
|
var downloadURL string
|
||||||
|
if s.externalUIDownloadURL != "" {
|
||||||
|
downloadURL = s.externalUIDownloadURL
|
||||||
|
} else {
|
||||||
|
downloadURL = "https://github.com/Dreamacro/clash-dashboard/archive/refs/heads/gh-pages.zip"
|
||||||
|
}
|
||||||
|
s.logger.Info("downloading external ui")
|
||||||
|
var detour adapter.Outbound
|
||||||
|
if s.externalUIDownloadDetour != "" {
|
||||||
|
outbound, loaded := s.router.Outbound(s.externalUIDownloadDetour)
|
||||||
|
if !loaded {
|
||||||
|
return E.New("detour outbound not found: ", s.externalUIDownloadDetour)
|
||||||
|
}
|
||||||
|
detour = outbound
|
||||||
|
} else {
|
||||||
|
detour = s.router.DefaultOutbound(N.NetworkTCP)
|
||||||
|
}
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return detour.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer httpClient.CloseIdleConnections()
|
||||||
|
response, err := httpClient.Get(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return E.New("download external ui failed: ", response.Status)
|
||||||
|
}
|
||||||
|
err = s.downloadZIP(filepath.Base(downloadURL), response.Body, s.externalUI)
|
||||||
|
if err != nil {
|
||||||
|
removeAllInDirectory(s.externalUI)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) downloadZIP(name string, body io.Reader, output string) error {
|
||||||
|
tempFile, err := C.CreateTemp(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
_, err = io.Copy(tempFile, body)
|
||||||
|
tempFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader, err := zip.OpenReader(tempFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
trimDir := zipIsInSingleDirectory(reader.File)
|
||||||
|
for _, file := range reader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pathElements := strings.Split(file.Name, "/")
|
||||||
|
if trimDir {
|
||||||
|
pathElements = pathElements[1:]
|
||||||
|
}
|
||||||
|
saveDirectory := output
|
||||||
|
if len(pathElements) > 1 {
|
||||||
|
saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...))
|
||||||
|
}
|
||||||
|
err = os.MkdirAll(saveDirectory, 0o755)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
savePath := filepath.Join(saveDirectory, pathElements[len(pathElements)-1])
|
||||||
|
err = downloadZIPEntry(file, savePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadZIPEntry(zipFile *zip.File, savePath string) error {
|
||||||
|
saveFile, err := os.Create(savePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer saveFile.Close()
|
||||||
|
reader, err := zipFile.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
return common.Error(io.Copy(saveFile, reader))
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAllInDirectory(directory string) {
|
||||||
|
dirEntries, err := os.ReadDir(directory)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, dirEntry := range dirEntries {
|
||||||
|
os.RemoveAll(filepath.Join(directory, dirEntry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zipIsInSingleDirectory(files []*zip.File) bool {
|
||||||
|
var singleDirectory string
|
||||||
|
for _, file := range files {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pathElements := strings.Split(file.Name, "/")
|
||||||
|
if len(pathElements) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if singleDirectory == "" {
|
||||||
|
singleDirectory = pathElements[0]
|
||||||
|
} else if singleDirectory != pathElements[0] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
|
@ -10,6 +10,10 @@ func SetBasePath(path string) {
|
||||||
C.SetBasePath(path)
|
C.SetBasePath(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetTempPath(path string) {
|
||||||
|
C.SetTempPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
func Version() string {
|
func Version() string {
|
||||||
return C.Version
|
return C.Version
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@ package option
|
||||||
type ClashAPIOptions struct {
|
type ClashAPIOptions struct {
|
||||||
ExternalController string `json:"external_controller,omitempty"`
|
ExternalController string `json:"external_controller,omitempty"`
|
||||||
ExternalUI string `json:"external_ui,omitempty"`
|
ExternalUI string `json:"external_ui,omitempty"`
|
||||||
|
ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"`
|
||||||
|
ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"`
|
||||||
Secret string `json:"secret,omitempty"`
|
Secret string `json:"secret,omitempty"`
|
||||||
DefaultMode string `json:"default_mode,omitempty"`
|
DefaultMode string `json:"default_mode,omitempty"`
|
||||||
StoreSelected bool `json:"store_selected,omitempty"`
|
StoreSelected bool `json:"store_selected,omitempty"`
|
||||||
|
|
Loading…
Reference in a new issue