diff --git a/dae/Makefile b/dae/Makefile index aed7a89f..89cd6edb 100644 --- a/dae/Makefile +++ b/dae/Makefile @@ -5,13 +5,13 @@ include $(TOPDIR)/rules.mk PKG_NAME:=dae -PKG_VERSION:=2026.09.20 -PKG_RELEASE:=3 +PKG_VERSION:=2026.09.23 +PKG_RELEASE:=1 -PKG_SOURCE:=dae-src-2026.09.20-2a846ee5a7e4.tar.gz +PKG_SOURCE:=dae-src-2026.09.23-c9f580bcaa8a.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) -PKG_HASH:=2a846ee5a7e40f2548f261bc3a0a5e4b1abe0f8324fa147354f574f7c410202c +PKG_HASH:=c9f580bcaa8a603ed0cd6ad0a27ab94c93a479ef48276a9017c0e29db0f5612d PKG_LICENSE:=AGPL-3.0-only PKG_LICENSE_FILE:=LICENSE diff --git a/daed/Makefile b/daed/Makefile index d387f2c8..0eb5a926 100644 --- a/daed/Makefile +++ b/daed/Makefile @@ -5,13 +5,13 @@ include $(TOPDIR)/rules.mk PKG_NAME:=daed -PKG_VERSION:=2026.09.20 -PKG_RELEASE:=4 +PKG_VERSION:=2026.09.23 +PKG_RELEASE:=1 -PKG_SOURCE:=daed-src-2026.09.20-d00211e69e94.tar.gz +PKG_SOURCE:=daed-src-2026.09.23-fcc80bac4542.tar.gz PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) -PKG_HASH:=d00211e69e94dba09b0f3ab67251b0dad880c7b90fbf55840d3abe576b4453c2 +PKG_HASH:=fcc80bac454253e8f7247a94b287175cd76af829bf16f88a6496e319377be735 PKG_LICENSE:=AGPL-3.0-only MIT PKG_LICENSE_FILES:=LICENSE wing/LICENSE diff --git a/daed/patches/0001-detach-bpf-hooks-on-dashboard-stop.patch b/daed/patches/0001-detach-bpf-hooks-on-dashboard-stop.patch deleted file mode 100644 index 51c9395a..00000000 --- a/daed/patches/0001-detach-bpf-hooks-on-dashboard-stop.patch +++ /dev/null @@ -1,23 +0,0 @@ -From: kenzok8 -Date: 2026-06-23 -Subject: [PATCH] daed: detach BPF hooks when stopping from dashboard - -Detach BPF hooks when the dashboard stops dae through EmptyConfig. -Reported: https://github.com/kenzok8/openwrt-daede/issues/13 ---- ---- a/dae/run.go -+++ b/dae/run.go -@@ -138,6 +138,13 @@ - /* dae-wing start */ - newConf := newReloadMsg.Config - /* dae-wing end */ -+ -+ // openwrt-daede: dashboard stop reloads to EmptyConfig; detach hooks. -+ if newConf == EmptyConfig { -+ if e := c.DetachBpfHooks(); e != nil { -+ log.Errorln("[Stop] DetachBpfHooks:", e) -+ } -+ } - // New logger. - oldLogOutput := log.Out - log = logrus.New() diff --git a/daed/patches/0002-use-fast-probe-for-manual-latency-tests.patch b/daed/patches/0002-use-fast-probe-for-manual-latency-tests.patch deleted file mode 100644 index 64b4b168..00000000 --- a/daed/patches/0002-use-fast-probe-for-manual-latency-tests.patch +++ /dev/null @@ -1,18 +0,0 @@ -From: kenzok8 -Date: 2026-06-29 -Subject: [PATCH] daed: use bounded probes for manual latency tests - -Use the existing bounded probe for manual dashboard latency tests. -Reported: https://github.com/kenzok8/openwrt-daede/issues/20 ---- ---- a/graphql/service/node/latency_mutation_utils.go -+++ b/graphql/service/node/latency_mutation_utils.go -@@ -108,7 +108,7 @@ func testSingleNodeLatency(option *diale - } - defer d.Close() - -- result, err := d.ProbeLatency() -+ result, err := d.ProbeLatencyFast() - if err != nil { - msg := err.Error() - resolver.MessageV = &msg diff --git a/daed/patches/0005-bound-startup-network-wait.patch b/daed/patches/0005-bound-startup-network-wait.patch index 07add31b..b97e040e 100644 --- a/daed/patches/0005-bound-startup-network-wait.patch +++ b/daed/patches/0005-bound-startup-network-wait.patch @@ -1,76 +1,80 @@ +From: kenzok8 +Date: 2026-09-23 +Subject: [PATCH] daed: bound startup network wait on new wing + +Return an error after 60 seconds and retry on the next generation. +--- diff --git a/dae/run.go b/dae/run.go -index d8f86c2..a0a665d 100644 +index 7c9a5cc..7f00748 100644 --- a/dae/run.go +++ b/dae/run.go -@@ -32,7 +32,8 @@ var ChReloadConfigs = make(chan *ReloadMessage) - var GracefullyExit = make(chan struct{}) - var EmptyConfig *daeConfig.Config - var c *control.ControlPlane +@@ -19,6 +19,7 @@ import ( + "github.com/daeuniverse/dae/control" + "github.com/daeuniverse/dae/pkg/config_parser" + "github.com/daeuniverse/dae/pkg/logger" ++ "github.com/daeuniverse/outbound/netproxy" + "github.com/daeuniverse/outbound/protocol/direct" + "github.com/mohae/deepcopy" + "github.com/sirupsen/logrus" +@@ -43,7 +44,8 @@ var EmptyConfig *daeConfig.Config + // active is the generation currently serving traffic. It is nil before the + // first generation is ready and while a reload has retired the old one. + var active atomic.Pointer[control.ControlPlane] -var onceWaitingNetwork sync.Once +var waitingNetworkMu sync.Mutex +var networkReady bool func init() { sections, err := config_parser.Parse(`global{} routing{}`) -@@ -52,6 +53,19 @@ func ControlPlane() (*control.ControlPlane, error) { +@@ -64,6 +66,19 @@ func ControlPlane() (*control.ControlPlane, error) { return c, nil } -+func waitForNetwork(log *logrus.Logger) error { ++func waitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) error { + waitingNetworkMu.Lock() + defer waitingNetworkMu.Unlock() + if networkReady { + return nil + } -+ if err := WaitForNetwork(log); err != nil { ++ if err := WaitForNetwork(log, directDialer); err != nil { + return err + } + networkReady = true + return nil +} + - func Run(log *logrus.Logger, conf *daeConfig.Config, externGeoDataDirs []string, disableTimestamp bool, dry bool) (err error) { - defer close(GracefullyExit) - // Not really run dae. -@@ -221,6 +235,9 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c - - // Deep copy to prevent modification. - conf = deepcopy.Copy(conf).(*daeConfig.Config) -+ if bpf == nil { -+ control.PurgeStaleTCFilters(log) -+ } - - // Init Direct Dialers. - direct.InitDirectDialers(conf.Global.FallbackResolver) -@@ -228,9 +245,9 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c + // generation is one control plane with its listener. retired is set before + // the plane is torn down so a Serve error from a plane that is being replaced + // is not mistaken for the active datapath dying. +@@ -345,9 +360,9 @@ func newControlPlane(log *logrus.Logger, dnsCache map[string]*control.DnsCache, if !conf.Global.DisableWaitingNetwork && len(conf.Global.WanInterface) > 0 { // Wait for network for WAN ready. - onceWaitingNetwork.Do(func() { -- WaitForNetwork(log) +- WaitForNetwork(log, directDialers.Symmetric) - }) -+ if err = waitForNetwork(log); err != nil { ++ if err = waitForNetwork(log, directDialers.Symmetric); err != nil { + return nil, err + } } /// Get subscription -> nodeList mapping. diff --git a/dae/utils.go b/dae/utils.go -index 65965a2..dc43cbc 100644 +index 2b4f6bf..93efb7e 100644 --- a/dae/utils.go +++ b/dae/utils.go @@ -94,8 +94,9 @@ func preprocessWanInterfaceAuto(params *daeConfig.Config) error { return nil } --func WaitForNetwork(log *logrus.Logger) { -+func WaitForNetwork(log *logrus.Logger) error { +-func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) { ++func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) error { epo := 5 * time.Second + deadline := time.Now().Add(60 * time.Second) client := http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (c net.Conn, err error) { -@@ -115,6 +116,9 @@ func WaitForNetwork(log *logrus.Logger) { +@@ -114,6 +115,9 @@ func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) { } log.Infoln("Waiting for network...") for i := 0; ; i++ { @@ -80,7 +84,7 @@ index 65965a2..dc43cbc 100644 resp, err := client.Get(cmd.CheckNetworkLinks[i%len(cmd.CheckNetworkLinks)]) if err != nil { log.Debugln("CheckNetwork:", err) -@@ -134,4 +138,5 @@ func WaitForNetwork(log *logrus.Logger) { +@@ -133,4 +137,5 @@ func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) { time.Sleep(epo) } log.Infoln("Network online.") diff --git a/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch b/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch index 985a832e..fa0203ef 100644 --- a/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch +++ b/daed/patches/0006-daed-strictly-reconcile-subscription-updates.patch @@ -1,22 +1,9 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: kenzok8 +Date: 2026-09-23 Subject: [PATCH] daed: strictly reconcile subscription updates -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit -订阅更新按节点链接精确对账,普通组里的失效节点删除,fixed 组固定节点转成独立节点保留。 - -数据库提交后按运行状态应用配置,补齐并发地址校验、导入失败回滚和 reload 超时恢复。 +Reconcile nodes by link, preserve fixed-group nodes, and apply committed changes. --- - cmd/run.go | 6 +- - graphql/mutation.go | 9 +- - graphql/root_schema.go | 2 +- - graphql/service/config/mutation_utils.go | 59 ++++- - .../service/subscription/mutation_utils.go | 181 +++++++++++---- - .../subscription/mutation_utils_test.go | 212 ++++++++++++++++++ - 6 files changed, 408 insertions(+), 61 deletions(-) - create mode 100644 graphql/service/subscription/mutation_utils_test.go - diff --git a/cmd/run.go b/cmd/run.go index 7dce577..978f46c 100644 --- a/cmd/run.go @@ -43,10 +30,10 @@ index 7dce577..978f46c 100644 } diff --git a/graphql/mutation.go b/graphql/mutation.go -index fcbf440..adb747c 100644 +index af065fd..d462ecc 100644 --- a/graphql/mutation.go +++ b/graphql/mutation.go -@@ -256,14 +256,7 @@ func (r *MutationResolver) SelectConfig(args *struct { +@@ -256,16 +256,7 @@ func (r *MutationResolver) SelectConfig(args *struct { func (r *MutationResolver) Run(args *struct { Dry bool }) (int32, error) { @@ -56,7 +43,9 @@ index fcbf440..adb747c 100644 - tx.Rollback() - return 0, err - } -- tx.Commit() +- if err = tx.Commit().Error; err != nil { +- return 0, fmt.Errorf("commit run: %w", err) +- } - return ret, nil + return config.Run(context.TODO(), args.Dry) } @@ -76,7 +65,7 @@ index 8b82db6..a96cace 100644 # updateSubscriptionLink is to update the subscription link without re-fetching nodes. diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go -index fd61554..feb94d3 100644 +index e6bf9da..8ab756b 100644 --- a/graphql/service/config/mutation_utils.go +++ b/graphql/service/config/mutation_utils.go @@ -21,6 +21,7 @@ import ( @@ -163,7 +152,7 @@ index fd61554..feb94d3 100644 // Running -> false diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go -index e55e65b..e01a342 100644 +index 7e6dc08..e87bf78 100644 --- a/graphql/service/subscription/mutation_utils.go +++ b/graphql/service/subscription/mutation_utils.go @@ -18,6 +18,7 @@ import ( @@ -174,7 +163,7 @@ index e55e65b..e01a342 100644 "github.com/daeuniverse/dae-wing/graphql/service/node" "github.com/daeuniverse/dae/common/subscription" "github.com/go-co-op/gocron" -@@ -171,8 +172,9 @@ func AutoUpdateVersionByIds(d *gorm.DB, ids []uint) (err error) { +@@ -175,8 +176,9 @@ func AutoUpdateVersionByIds(d *gorm.DB, ids []uint) (err error) { } var ( @@ -186,7 +175,7 @@ index e55e65b..e01a342 100644 ) func UpdateAll(ctx context.Context) { -@@ -258,68 +260,161 @@ func Update(ctx context.Context, _id graphql.ID) (r *Resolver, err error) { +@@ -262,68 +264,161 @@ func Update(ctx context.Context, _id graphql.ID) (r *Resolver, err error) { return &Resolver{Subscription: m}, nil } @@ -212,7 +201,7 @@ index e55e65b..e01a342 100644 - tx := db.BeginTx(ctx) - defer func() { - if err == nil { -- tx.Commit() +- err = tx.Commit().Error + current := make(map[string]struct{}, len(existing)) + var staleIds []uint + for _, n := range existing { @@ -303,7 +292,7 @@ index e55e65b..e01a342 100644 } - hasAnyCandidate := false for _, r := range result { -- if r.Error == nil { +- if r.Error == nil || *r.Error == node.DuplicatedError.Error() { - hasAnyCandidate = true - break + if r.Error != nil { @@ -388,7 +377,7 @@ index e55e65b..e01a342 100644 return &m, nil } -@@ -399,6 +494,8 @@ func UpdateLink(ctx context.Context, _id graphql.ID, link string) (r *Resolver, +@@ -406,6 +501,8 @@ func UpdateLink(ctx context.Context, _id graphql.ID, link string) (r *Resolver, if err != nil { return nil, err } @@ -397,11 +386,11 @@ index e55e65b..e01a342 100644 tx := db.BeginTx(ctx) defer func() { -diff --git a/graphql/service/subscription/mutation_utils_test.go b/graphql/service/subscription/mutation_utils_test.go +diff --git a/graphql/service/subscription/reconcile_patch_test.go b/graphql/service/subscription/reconcile_patch_test.go new file mode 100644 index 0000000..18ff9b9 --- /dev/null -+++ b/graphql/service/subscription/mutation_utils_test.go ++++ b/graphql/service/subscription/reconcile_patch_test.go @@ -0,0 +1,212 @@ +/* + * SPDX-License-Identifier: AGPL-3.0-only diff --git a/daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch b/daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch deleted file mode 100644 index fe7d04ce..00000000 --- a/daed/patches/0011-daed-apply-subscription-cron-changes-without-restart.patch +++ /dev/null @@ -1,44 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -Subject: [PATCH] daed: apply subscription cron changes without restart -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -改 cron 后先提交事务再重建调度器。 - -原来在事务里就去重建,调度器另开连接读到的还是旧表达式,必须重启 daed 才生效。 ---- - graphql/service/subscription/mutation_utils.go | 12 +++++++++--- - 1 file changed, 9 insertions(+), 3 deletions(-) - -diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go -index e2aec32..4f6bcfc 100644 ---- a/graphql/service/subscription/mutation_utils.go -+++ b/graphql/service/subscription/mutation_utils.go -@@ -561,10 +561,12 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable - } - - tx := db.BeginTx(ctx) -+ if tx.Error != nil { -+ return nil, tx.Error -+ } -+ committed := false - defer func() { -- if err == nil { -- tx.Commit() -- } else { -+ if !committed { - tx.Rollback() - } - }() -@@ -583,6 +585,10 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable - }).Error; err != nil { - return nil, err - } -+ if err = tx.Commit().Error; err != nil { -+ return nil, err -+ } -+ committed = true - - // Update scheduler - RemoveUpdateScheduler(id) diff --git a/daed/patches/0011-preserve-reload-safety-on-new-wing.patch b/daed/patches/0011-preserve-reload-safety-on-new-wing.patch new file mode 100644 index 00000000..69299183 --- /dev/null +++ b/daed/patches/0011-preserve-reload-safety-on-new-wing.patch @@ -0,0 +1,28 @@ +From: kenzok8 +Date: 2026-09-23 +Subject: [PATCH] daed: keep reload callbacks nonblocking + +A timed-out request must not block the daemon when reload later finishes. +--- +diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go +index 5996eaa..64229e8 100644 +--- a/graphql/service/config/mutation_utils.go ++++ b/graphql/service/config/mutation_utils.go +@@ -298,7 +298,7 @@ func ApplyIfRunning(ctx context.Context) error { + func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { + //// Dry run. + if noLoad { +- ch := make(chan error) ++ ch := make(chan error, 1) + reloadMsg := &dae.ReloadMessage{ + Config: dae.EmptyConfig, + Callback: ch, +@@ -523,7 +523,7 @@ func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { + readTx = nil + + /// Reload with current config. +- chReloadCallback := make(chan error) ++ chReloadCallback := make(chan error, 1) + reloadMsg := &dae.ReloadMessage{ + Config: c, + Callback: chReloadCallback, diff --git a/daed/patches/0012-adapt-wing-to-new-core.patch b/daed/patches/0012-adapt-wing-to-new-core.patch deleted file mode 100644 index ec5af516..00000000 --- a/daed/patches/0012-adapt-wing-to-new-core.patch +++ /dev/null @@ -1,837 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: kenzok8 -Date: Sun, 20 Sep 2026 16:59:23 +0800 -Subject: [PATCH] Adapt wing to new dae core -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -改动:适配新核心生命周期,并修复空配置停止和重载回滚。 - -原因:旧接口删除且新控制面交接流程已改变。 ---- - dae-core/config/marshal.go | 5 + - dae/run.go | 402 +++++++++++++----- - dae/run_handoff_test.go | 33 ++ - dae/runtime.go | 6 +- - dae/utils.go | 9 +- - db/node_utils.go | 3 +- - graphql/service/config/mutation_utils.go | 9 +- - graphql/service/node/latency_cache.go | 2 +- - .../service/node/latency_mutation_utils.go | 59 ++- - graphql/service/routing/resolver.go | 2 +- - 10 files changed, 415 insertions(+), 115 deletions(-) - create mode 100644 dae/run_handoff_test.go - -diff --git a/dae-core/config/marshal.go b/dae-core/config/marshal.go -index f68006a..69a5325 100644 ---- a/dae-core/config/marshal.go -+++ b/dae-core/config/marshal.go -@@ -42,6 +42,11 @@ type Marshaller struct { - buf bytes.Buffer - } - -+// Bytes returns the serialized section buffer without exposing mutable state. -+func (m *Marshaller) Bytes() []byte { -+ return m.buf.Bytes() -+} -+ - func (m *Marshaller) writeLine(depth int, line string) { - if depth < 0 { - depth = 0 -diff --git a/dae/run.go b/dae/run.go -index a0a665d..96b579c 100644 ---- a/dae/run.go -+++ b/dae/run.go -@@ -6,6 +6,8 @@ - package dae - - import ( -+ "context" -+ "errors" - "fmt" - "net/netip" - "runtime" -@@ -28,6 +30,22 @@ type ReloadMessage struct { - Callback chan<- error - } - -+func closeReloadResources(closers ...func() error) error { -+ var errs error -+ for _, closeResource := range closers { -+ if closeResource != nil { -+ errs = errors.Join(errs, closeResource()) -+ } -+ } -+ return errs -+} -+ -+func notifyReloadCallback(callback chan<- error, err error) { -+ if callback != nil { -+ callback <- err -+ } -+} -+ - var ChReloadConfigs = make(chan *ReloadMessage) - var GracefullyExit = make(chan struct{}) - var EmptyConfig *daeConfig.Config -@@ -77,88 +95,115 @@ func Run(log *logrus.Logger, conf *daeConfig.Config, externGeoDataDirs []string, - case nil: - break dryLoop - default: -- newConf.Callback <- nil -+ notifyReloadCallback(newConf.Callback, nil) - } - } - return nil - } - - // New c. -- c, err = newControlPlane(log, nil, nil, conf, externGeoDataDirs) -+ processSessions := control.NewSessionManager(context.Background()) -+ defer func() { _ = processSessions.Close() }() -+ c, err = newControlPlane(context.Background(), log, nil, nil, conf, externGeoDataDirs, false) - if err != nil { - return err - } -+ if err = c.AttachSessionManager(processSessions); err != nil { -+ _ = c.Close() -+ return err -+ } -+ datapathDetached := false -+ if conf == EmptyConfig { -+ if err = c.DetachBpfHooks(); err != nil { -+ _ = c.Close() -+ return fmt.Errorf("detach initial empty datapath: %w", err) -+ } -+ datapathDetached = true -+ } - -- // Serve tproxy TCP/UDP server util signals. - var listener *control.Listener -+ if err = control.GetDaeNetns().With(func() error { -+ var listenErr error -+ listener, listenErr = c.Listen(conf.Global.TproxyPort) -+ if listenErr != nil { -+ return listenErr -+ } -+ return listener.ValidateCurrentNetns() -+ }); err != nil { -+ if listener != nil { -+ _ = listener.Close() -+ } -+ _ = c.Close() -+ return fmt.Errorf("listen in dae netns: %w", err) -+ } -+ readyChan := make(chan bool, 1) -+ serveDone := make(chan error, 1) -+ go func() { serveDone <- c.Serve(readyChan, listener) }() -+ if !<-readyChan { -+ serveErr := <-serveDone -+ _ = closeReloadResources(listener.Close, c.Close) -+ if serveErr == nil { -+ serveErr = fmt.Errorf("initial control plane was not ready") -+ } -+ return serveErr -+ } -+ log.Infoln("Ready") - go func() { -- readyChan := make(chan bool, 1) -- go func() { -- <-readyChan -- log.Infoln("Ready") -- }() -- control.GetDaeNetns().With(func() error { -- if listener, err = c.ListenAndServe(readyChan, conf.Global.TproxyPort); err != nil { -- log.Errorln("ListenAndServe:", err) -- } -- return err -- }) -- // Exit -+ if serveErr := <-serveDone; serveErr != nil { -+ log.WithError(serveErr).Errorln("control plane serve stopped") -+ } - ChReloadConfigs <- nil - }() - reloading := false -- /* dae-wing start */ -- var errReload error -- var chCallback chan<- error -- /* dae-wing end */ - loop: - for newReloadMsg := range ChReloadConfigs { - switch newReloadMsg { - case nil: -- /* dae-wing start */ -- // We will receive nil after control plane being Closed. -- // We'll judge if we are in a reloading. -- /* dae-wing end */ -- - if reloading { -- if listener == nil { -- // Failed to listen. Exit. -- break loop -- } -- // Serve. -+ // The old generation's Serve loop reports its shutdown after the -+ // candidate has already taken over the cloned listener. - reloading = false -- log.Warnln("[Reload] Serve") -- readyChan := make(chan bool, 1) -- go func() { -- if err := c.Serve(readyChan, listener); err != nil { -- log.Errorln("ListenAndServe:", err) -- } -- // Exit -- ChReloadConfigs <- nil -- }() -- <-readyChan -- log.Warnln("[Reload] Finished") -- /* dae-wing start */ -- chCallback <- errReload -- /* dae-wing end */ -+ continue - } else { -- // Listening error. - break loop - } - default: - // Reload signal. - log.Warnln("[Reload] Received reload signal; prepare to reload") - -- /* dae-wing start */ - newConf := newReloadMsg.Config -- /* dae-wing end */ -- -- // openwrt-daede: dashboard stop reloads to EmptyConfig; detach hooks. -- if newConf == EmptyConfig { -- if e := c.DetachBpfHooks(); e != nil { -- log.Errorln("[Stop] DetachBpfHooks:", e) -+ if datapathDetached && newConf == EmptyConfig { -+ notifyReloadCallback(newReloadMsg.Callback, nil) -+ continue -+ } -+ oldC := c -+ wasStopped := datapathDetached -+ failReload := func(cause, cleanupErr error) error { -+ cause = errors.Join(cause, cleanupErr) -+ if cleanupErr == nil && wasStopped { -+ if detachErr := oldC.DetachBpfHooks(); detachErr != nil { -+ cause = errors.Join(cause, fmt.Errorf("restore stopped datapath: %w", detachErr)) -+ cleanupErr = detachErr -+ } else { -+ datapathDetached = true -+ } - } -+ notifyReloadCallback(newReloadMsg.Callback, cause) -+ if cleanupErr != nil { -+ return cause -+ } -+ return nil - } -+ if wasStopped { -+ if restoreErr := oldC.RestoreDatapathForReloadRollback(); restoreErr != nil { -+ if fatalErr := failReload(fmt.Errorf("prepare stopped datapath: %w", restoreErr), nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ datapathDetached = false -+ } -+ - // New logger. - oldLogOutput := log.Out - log = logrus.New() -@@ -167,65 +212,223 @@ loop: - log.SetOutput(oldLogOutput) // FIXME: THIS IS A HACK. - logrus.SetOutput(oldLogOutput) - -- // New control plane. -- obj := c.EjectBpf() - var dnsCache map[string]*control.DnsCache - if conf.Dns.IpVersionPrefer == newConf.Dns.IpVersionPrefer { - // Only keep dns cache when ip version preference not change. - dnsCache = c.CloneDnsCache() - } -- log.Warnln("[Reload] Load new control plane") -- newC, err := newControlPlane(log, obj, dnsCache, newConf, externGeoDataDirs) -+ log.Warnln("[Reload] Prepare new control plane") -+ freshState, err := c.SnapshotFreshDatapathState() - if err != nil { -- /* dae-wing start */ -- errReload = err -- /* dae-wing end */ -- -- log.WithFields(logrus.Fields{ -- "err": err, -- }).Errorln("[Reload] Failed to reload; try to roll back configuration") -- // Load last config back. -- newC, err = newControlPlane(log, obj, dnsCache, conf, externGeoDataDirs) -- if err != nil { -- obj.Close() -- c.Close() -- log.WithFields(logrus.Fields{ -- "err": err, -- }).Fatalln("[Reload] Failed to roll back configuration") -+ if fatalErr := failReload(err, nil); fatalErr != nil { -+ return fatalErr - } -- newConf = conf -- log.Errorln("[Reload] Last reload failed; rolled back configuration") -- } else { -- log.Warnln("[Reload] Stopped old control plane") -- -- /* dae-wing start */ -- errReload = nil -- /* dae-wing end */ -+ continue - } -+ newC, err := newControlPlane(context.Background(), log, freshState, dnsCache, newConf, externGeoDataDirs, true) -+ if err == nil { -+ err = newC.AttachSessionManager(processSessions) -+ } -+ if err != nil { -+ var cleanupErr error -+ if newC != nil { -+ cleanupErr = newC.Close() -+ } -+ if fatalErr := failReload(err, cleanupErr); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ stagedListener, err := listener.Clone() -+ if err != nil { -+ if fatalErr := failReload(fmt.Errorf("clone listener: %w", err), newC.Close()); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ if err = oldC.StopDNSListener(); err != nil { -+ cleanupErr := closeReloadResources(stagedListener.Close, newC.Close, oldC.RestartDNSListener) -+ if fatalErr := failReload(fmt.Errorf("stop old DNS listener: %w", err), cleanupErr); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ dnsStopped := true -+ readyChan := make(chan bool, 1) -+ serveDone := make(chan error, 1) -+ go func() { serveDone <- newC.Serve(readyChan, stagedListener) }() -+ ready := <-readyChan -+ if !ready { -+ serveErr := <-serveDone -+ if serveErr == nil { -+ serveErr = fmt.Errorf("prepared control plane was not ready") -+ } -+ cleanupErr := closeReloadResources( -+ stagedListener.Close, -+ newC.Close, -+ oldC.RestartDNSListener, -+ ) -+ dnsStopped = false -+ if fatalErr := failReload(serveErr, cleanupErr); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ hookPrepared := false -+ provisionalOwner := false -+ hookFlipCommitted := false -+ hookSetAdopted := false -+ flowDatapathAdopted := false -+ rollbackCleanupFailed := false -+ rollbackCandidate := func(cause error) error { -+ var rollbackErr error -+ addRollbackErr := func(err error) { -+ if err != nil { -+ rollbackCleanupFailed = true -+ rollbackErr = errors.Join(rollbackErr, err) -+ } -+ } -+ if flowDatapathAdopted { -+ addRollbackErr(oldC.AdoptProcessFlowDatapath(newC)) -+ } -+ if hookSetAdopted { -+ if ownerErr := newC.RestorePreparedTCHookSet(oldC); ownerErr != nil { -+ rollbackCleanupFailed = true -+ return errors.Join(cause, rollbackErr, fmt.Errorf("restore TC HookSet owner: %w", ownerErr)) -+ } -+ } -+ if hookFlipCommitted { -+ addRollbackErr(newC.RollbackPreparedBpfHookFlip()) -+ } -+ if hookPrepared && !hookSetAdopted && !hookFlipCommitted { -+ addRollbackErr(newC.ClearPreparedTCHookHandoff()) -+ } -+ if provisionalOwner { -+ newC.UnregisterProvisionalRoutingEpochExecutionOwner() -+ } -+ addRollbackErr(newC.DetachBpfHooks()) -+ addRollbackErr(stagedListener.Close()) -+ addRollbackErr(newC.Close()) -+ if dnsStopped { -+ addRollbackErr(oldC.RestartDNSListener()) -+ dnsStopped = false -+ } -+ return errors.Join(cause, rollbackErr) -+ } -+ if err = newC.PrepareTCHookHandoff(oldC); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ hookPrepared = true -+ if err = newC.RegisterProvisionalRoutingEpochExecutionOwner(); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ provisionalOwner = true -+ if err = newC.CommitPreparedBpfHookFlip(); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ hookFlipCommitted = true -+ if err = newC.AdoptPreparedTCHookSet(oldC); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ hookSetAdopted = true -+ if err = newC.AdoptProcessFlowDatapath(oldC); err != nil { -+ rollbackErr := rollbackCandidate(err) -+ if rollbackCleanupFailed { -+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr) -+ return rollbackErr -+ } -+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil { -+ return fatalErr -+ } -+ continue -+ } -+ flowDatapathAdopted = true -+ newC.UnregisterProvisionalRoutingEpochExecutionOwner() -+ provisionalOwner = false -+ if err = newC.FinalizePreparedTCHooks(); err != nil { -+ fatalErr := errors.Join(fmt.Errorf("finalize prepared TC hooks: %w", err), closeReloadResources( -+ listener.Close, -+ stagedListener.Close, -+ newC.Close, -+ oldC.Close, -+ )) -+ notifyReloadCallback(newReloadMsg.Callback, fatalErr) -+ return fatalErr -+ } -+ hookPrepared = false -+ hookFlipCommitted = false -+ hookSetAdopted = false - -- // Inject bpf objects into the new control plane life-cycle. -- newC.InjectBpf(obj) -- -- // Prepare new context. -- oldC := c -+ oldListener := listener - c = newC - conf = newConf -+ listener = stagedListener -+ datapathDetached = false -+ retireErr := closeReloadResources(oldListener.Close, oldC.Close) -+ if newConf == EmptyConfig { -+ if retireErr != nil { -+ fatalErr := errors.Join(fmt.Errorf("retire previous control plane: %w", retireErr), closeReloadResources(stagedListener.Close, newC.Close)) -+ notifyReloadCallback(newReloadMsg.Callback, fatalErr) -+ return fatalErr -+ } -+ if detachErr := newC.DetachBpfHooks(); detachErr != nil { -+ fatalErr := errors.Join(fmt.Errorf("detach stopped datapath: %w", detachErr), closeReloadResources(stagedListener.Close, newC.Close)) -+ notifyReloadCallback(newReloadMsg.Callback, fatalErr) -+ return fatalErr -+ } -+ datapathDetached = true -+ } else if retireErr != nil { -+ log.WithError(retireErr).Warnln("[Reload] Failed to close old control plane") -+ } - reloading = true -- /* dae-wing start */ -- chCallback = newReloadMsg.Callback -- /* dae-wing end */ -- -- // Ready to close. -- oldC.Close() -+ notifyReloadCallback(newReloadMsg.Callback, nil) -+ go func(done <-chan error) { -+ if serveErr := <-done; serveErr != nil { -+ log.WithError(serveErr).Errorln("control plane serve stopped") -+ } -+ ChReloadConfigs <- nil -+ }(serveDone) - } - } -- if e := c.Close(); e != nil { -+ if e := closeReloadResources(listener.Close, c.Close); e != nil { - return fmt.Errorf("close control plane: %w", e) - } - return nil - } - --func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*control.DnsCache, conf *daeConfig.Config, externGeoDataDirs []string) (c *control.ControlPlane, err error) { -+func newControlPlane(ctx context.Context, log *logrus.Logger, bpf interface{}, dnsCache map[string]*control.DnsCache, conf *daeConfig.Config, externGeoDataDirs []string, prepared bool) (c *control.ControlPlane, err error) { - - // Print configuration. - if log.IsLevelEnabled(logrus.DebugLevel) { -@@ -239,10 +442,6 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c - control.PurgeStaleTCFilters(log) - } - -- // Init Direct Dialers. -- direct.InitDirectDialers(conf.Global.FallbackResolver) -- netutils.FallbackDns = netip.MustParseAddrPort(conf.Global.FallbackResolver) -- - if !conf.Global.DisableWaitingNetwork && len(conf.Global.WanInterface) > 0 { - // Wait for network for WAN ready. - if err = waitForNetwork(log); err != nil { -@@ -266,7 +465,10 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c - } - - // New dae control plane. -- c, err = control.NewControlPlane( -+ directDialers := direct.NewDirectDialers(conf.Global.FallbackResolver) -+ systemDNSResolver := netutils.NewSystemDNSResolver(netip.MustParseAddrPort(conf.Global.FallbackResolver)) -+ c, err = control.NewControlPlaneWithContextOptions( -+ ctx, - log, - bpf, - dnsCache, -@@ -276,6 +478,14 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c - &conf.Global, - &conf.Dns, - externGeoDataDirs, -+ control.ControlPlaneBuildOptions{ -+ DelayDatapathCommit: prepared, -+ DelayDNSListenerStart: prepared, -+ IsReload: prepared || bpf != nil, -+ DirectDialer: directDialers.Symmetric, -+ FullconeDirectDialer: directDialers.Fullcone, -+ SystemDNSResolver: systemDNSResolver, -+ }, - ) - if err != nil { - return nil, err -diff --git a/dae/run_handoff_test.go b/dae/run_handoff_test.go -new file mode 100644 -index 0000000..0ffb2ec ---- /dev/null -+++ b/dae/run_handoff_test.go -@@ -0,0 +1,33 @@ -+package dae -+ -+import ( -+ "errors" -+ "reflect" -+ "testing" -+) -+ -+func TestCloseReloadResourcesOrder(t *testing.T) { -+ var got []string -+ err := closeReloadResources( -+ func() error { got = append(got, "old-listener"); return nil }, -+ func() error { got = append(got, "staged-listener"); return nil }, -+ func() error { got = append(got, "new-control-plane"); return nil }, -+ func() error { got = append(got, "old-control-plane"); return nil }, -+ ) -+ if err != nil { -+ t.Fatalf("closeReloadResources() error = %v", err) -+ } -+ want := []string{"old-listener", "staged-listener", "new-control-plane", "old-control-plane"} -+ if !reflect.DeepEqual(got, want) { -+ t.Fatalf("close order = %v, want %v", got, want) -+ } -+} -+ -+func TestCloseReloadResourcesJoinsErrors(t *testing.T) { -+ one := errors.New("old listener") -+ two := errors.New("old control plane") -+ err := closeReloadResources(func() error { return one }, func() error { return two }) -+ if !errors.Is(err, one) || !errors.Is(err, two) { -+ t.Fatalf("joined error = %v, want both close errors", err) -+ } -+} -diff --git a/dae/runtime.go b/dae/runtime.go -index 2ab0d68..af46089 100644 ---- a/dae/runtime.go -+++ b/dae/runtime.go -@@ -30,18 +30,16 @@ type RuntimeOverview struct { - } - - func GetRuntimeOverview(windowSec int, maxPoints int) (*RuntimeOverview, error) { -- activeTCPConnections := 0 - ctl, err := ControlPlane() -+ var snapshot control.RuntimeStatsSnapshot - if err != nil { - if !errors.Is(err, ErrControlPlaneNotInit) { - return nil, err - } - } else { -- activeTCPConnections = ctl.ActiveTCPConnections() -+ snapshot = ctl.SnapshotRuntimeStats(windowSec, maxPoints) - } - -- snapshot := control.SnapshotRuntimeStats(activeTCPConnections, control.DefaultUdpEndpointPool.Count(), windowSec, maxPoints) -- - samples := make([]RuntimeTrafficSample, 0, len(snapshot.Samples)) - for _, sample := range snapshot.Samples { - samples = append(samples, RuntimeTrafficSample{ -diff --git a/dae/utils.go b/dae/utils.go -index dc43cbc..ca61c85 100644 ---- a/dae/utils.go -+++ b/dae/utils.go -@@ -32,8 +32,11 @@ var ( - EmptyGlobalSection = `global {}` - ) - --func NecessaryOutbounds(routing *daeConfig.Routing) (outbounds []string) { -- f := daeConfig.FunctionOrStringToFunction(routing.Fallback) -+func NecessaryOutbounds(routing *daeConfig.Routing) (outbounds []string, err error) { -+ f, err := daeConfig.ParseFunctionOrString(routing.Fallback) -+ if err != nil { -+ return nil, err -+ } - outbounds = append(outbounds, f.Name) - for _, r := range routing.Rules { - outbound := r.Outbound.Name -@@ -42,7 +45,7 @@ func NecessaryOutbounds(routing *daeConfig.Routing) (outbounds []string) { - } - outbounds = append(outbounds, outbound) - } -- return daeCommon.Deduplicate(outbounds) -+ return daeCommon.Deduplicate(outbounds), nil - } - - func ParseConfig(globalSection *string, dnsSection *string, routingSection *string) (*daeConfig.Config, error) { -diff --git a/db/node_utils.go b/db/node_utils.go -index b7b43b8..7ca8fc3 100644 ---- a/db/node_utils.go -+++ b/db/node_utils.go -@@ -6,6 +6,7 @@ - package db - - import ( -+ "context" - "strings" - - "github.com/daeuniverse/dae-wing/common" -@@ -25,7 +26,7 @@ func NewNodeModel(link string, tag *string, subscriptionId *uint) (*Node, error) - } - _tag = *tag - } -- d, err := dialer.NewFromLink(&dialer.GlobalOption{ -+ d, err := dialer.NewFromLinkContext(context.Background(), &dialer.GlobalOption{ - Log: logrus.StandardLogger(), - }, dialer.InstanceOption{DisableCheck: false}, link, _tag) - if err != nil { -diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go -index 59f0ebd..c7d6b78 100644 ---- a/graphql/service/config/mutation_utils.go -+++ b/graphql/service/config/mutation_utils.go -@@ -298,7 +298,7 @@ func ApplyIfRunning(ctx context.Context) error { - func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { - //// Dry run. - if noLoad { -- ch := make(chan error) -+ ch := make(chan error, 1) - reloadMsg := &dae.ReloadMessage{ - Config: dae.EmptyConfig, - Callback: ch, -@@ -384,7 +384,10 @@ func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { - } - /// Fill in necessary groups and nodes. - // Find groups needed by routing. -- outbounds := dae.NecessaryOutbounds(&c.Routing) -+ outbounds, err := dae.NecessaryOutbounds(&c.Routing) -+ if err != nil { -+ return 0, err -+ } - var groups []db.Group - q = d.Model(&db.Group{}). - Where("name in ?", outbounds). -@@ -523,7 +526,7 @@ func runLocked(ctx context.Context, noLoad bool) (n int32, err error) { - readTx = nil - - /// Reload with current config. -- chReloadCallback := make(chan error) -+ chReloadCallback := make(chan error, 1) - reloadMsg := &dae.ReloadMessage{ - Config: c, - Callback: chReloadCallback, -diff --git a/graphql/service/node/latency_cache.go b/graphql/service/node/latency_cache.go -index ba66794..e0ec91e 100644 ---- a/graphql/service/node/latency_cache.go -+++ b/graphql/service/node/latency_cache.go -@@ -162,7 +162,7 @@ func refreshLatencyCache(ctx context.Context) error { - return err - } - -- results := testLatencyResultsForNodes(option, nodes) -+ results := testLatencyResultsForNodes(ctx, option, nodes) - storeLatencyResults(results) - - if ctl, err := dae.ControlPlane(); err == nil { -diff --git a/graphql/service/node/latency_mutation_utils.go b/graphql/service/node/latency_mutation_utils.go -index 056f9fa..5026b33 100644 ---- a/graphql/service/node/latency_mutation_utils.go -+++ b/graphql/service/node/latency_mutation_utils.go -@@ -8,6 +8,8 @@ package node - import ( - "context" - "io" -+ "net/netip" -+ "strings" - "sync" - "time" - -@@ -15,6 +17,7 @@ import ( - "github.com/daeuniverse/dae-wing/dae" - "github.com/daeuniverse/dae-wing/db" - dialer "github.com/daeuniverse/dae/component/outbound/dialer" -+ "github.com/daeuniverse/outbound/netproxy" - "github.com/graph-gophers/graphql-go" - "github.com/sirupsen/logrus" - ) -@@ -32,12 +35,12 @@ func TestLatencies(ctx context.Context, ids *[]graphql.ID) ([]*LatencyResolver, - return nil, err - } - -- results := testLatencyResultsForNodes(option, nodes) -+ results := testLatencyResultsForNodes(ctx, option, nodes) - storeLatencyResults(results) - return results, nil - } - --func testLatencyResultsForNodes(option *dialer.GlobalOption, nodes []db.Node) []*LatencyResolver { -+func testLatencyResultsForNodes(ctx context.Context, option *dialer.GlobalOption, nodes []db.Node) []*LatencyResolver { - results := make([]*LatencyResolver, len(nodes)) - sem := make(chan struct{}, latencyProbeConcurrency) - var wg sync.WaitGroup -@@ -51,7 +54,7 @@ func testLatencyResultsForNodes(option *dialer.GlobalOption, nodes []db.Node) [] - defer func() { <-sem }() - - node := nodes[index] -- results[index] = testSingleNodeLatency(option, &node) -+ results[index] = testSingleNodeLatency(ctx, option, &node) - }() - } - -@@ -93,14 +96,14 @@ func latencyProbeNodes(ctx context.Context, ids *[]graphql.ID) ([]db.Node, error - return nodes, nil - } - --func testSingleNodeLatency(option *dialer.GlobalOption, node *db.Node) *LatencyResolver { -+func testSingleNodeLatency(ctx context.Context, option *dialer.GlobalOption, node *db.Node) *LatencyResolver { - resolver := &LatencyResolver{ - NodeID: node.ID, - AliveVal: false, - TestedAtV: time.Now(), - } - -- d, err := dialer.NewFromLink(option, dialer.InstanceOption{DisableCheck: false}, node.Link, "") -+ d, err := dialer.NewFromLinkContext(ctx, option, dialer.InstanceOption{DisableCheck: false}, node.Link, "") - if err != nil { - msg := err.Error() - resolver.MessageV = &msg -@@ -108,7 +111,7 @@ func testSingleNodeLatency(option *dialer.GlobalOption, node *db.Node) *LatencyR - } - defer d.Close() - -- result, err := d.ProbeLatencyFast() -+ result, err := probeLatencyFast(ctx, d) - if err != nil { - msg := err.Error() - resolver.MessageV = &msg -@@ -128,3 +131,47 @@ func testSingleNodeLatency(option *dialer.GlobalOption, node *db.Node) *LatencyR - } - return resolver - } -+ -+func probeLatencyFast(ctx context.Context, d *dialer.Dialer) (*dialer.LatencyProbeResult, error) { -+ opt, err := d.TcpCheckOptionRaw.Option() -+ if err != nil { -+ return nil, err -+ } -+ network, err := netproxy.ParseMagicNetwork(d.TcpCheckOptionRaw.ResolverNetwork) -+ if err != nil { -+ return nil, err -+ } -+ result := &dialer.LatencyProbeResult{CheckedAt: time.Now()} -+ var messages []string -+ for _, probe := range []struct { -+ index int -+ ip netip.Addr -+ }{ -+ {index: dialer.IdxTcp4, ip: opt.Ip4}, -+ {index: dialer.IdxTcp6, ip: opt.Ip6}, -+ } { -+ if !probe.ip.IsValid() { -+ continue -+ } -+ probeCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond) -+ started := time.Now() -+ ok, probeErr := d.HttpCheck(probeCtx, probe.index, opt.Url, probe.ip, opt.Method, network.Mark, network.Mptcp) -+ cancel() -+ if ok { -+ latency := time.Since(started) -+ if !result.Alive || latency < result.Latency { -+ result.Latency = latency -+ } -+ result.Alive = true -+ } else if probeErr != nil { -+ messages = append(messages, probeErr.Error()) -+ } -+ } -+ if !result.Alive { -+ result.Message = strings.Join(messages, "; ") -+ if result.Message == "" { -+ result.Message = "no latency result" -+ } -+ } -+ return result, nil -+} -diff --git a/graphql/service/routing/resolver.go b/graphql/service/routing/resolver.go -index 7822d64..3ed61aa 100644 ---- a/graphql/service/routing/resolver.go -+++ b/graphql/service/routing/resolver.go -@@ -41,7 +41,7 @@ func (r *Resolver) Selected() bool { - return r.Model.Selected - } - --func (r *Resolver) ReferenceGroups() (outbounds []string) { -+func (r *Resolver) ReferenceGroups() (outbounds []string, err error) { - return dae.NecessaryOutbounds(r.DaeRouting) - } - diff --git a/daed/patches/0012-export-config-marshaller-bytes.patch b/daed/patches/0012-export-config-marshaller-bytes.patch new file mode 100644 index 00000000..115d5745 --- /dev/null +++ b/daed/patches/0012-export-config-marshaller-bytes.patch @@ -0,0 +1,21 @@ +From: kenzok8 +Date: 2026-09-23 +Subject: [PATCH] daed: expose config marshaller bytes + +The current wing generator calls Marshaller.Bytes when updating global config. +--- +diff --git a/dae-core/config/marshal.go b/dae-core/config/marshal.go +index f68006a..dfd6777 100644 +--- a/dae-core/config/marshal.go ++++ b/dae-core/config/marshal.go +@@ -42,6 +42,10 @@ type Marshaller struct { + buf bytes.Buffer + } + ++func (m *Marshaller) Bytes() []byte { ++ return m.buf.Bytes() ++} ++ + func (m *Marshaller) writeLine(depth int, line string) { + if depth < 0 { + depth = 0 diff --git a/luci-app-baidudrive/Makefile b/luci-app-baidudrive/Makefile index ac36127f..e3c15bb1 100644 --- a/luci-app-baidudrive/Makefile +++ b/luci-app-baidudrive/Makefile @@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=baidudrive PKG_VERSION:=1.0.2 -PKG_RELEASE:=2 -LUCI_DEPENDS:=+baidudrive +luci-compat +luci-lib-linkeaseauth +PKG_RELEASE:=4 +LUCI_DEPENDS:=+baidudrive +luci-compat +luci-lib-linkeaseauth +linkease-app-entry LUCI_MINIFY_CSS:=0 LUCI_MINIFY_JS:=0 diff --git a/luci-app-baidudrive/luasrc/view/baidudrive/baidudrive_status.htm b/luci-app-baidudrive/luasrc/view/baidudrive/baidudrive_status.htm index ecbde20e..695e96bc 100644 --- a/luci-app-baidudrive/luasrc/view/baidudrive/baidudrive_status.htm +++ b/luci-app-baidudrive/luasrc/view/baidudrive/baidudrive_status.htm @@ -9,7 +9,7 @@ XHR.poll(5, '<%=url("admin/services/baidudrive_status")%>', null, function(x, st } if (st.running) { el.innerHTML = '
<%:The BaiduDrive service is running.%>' - + "

\" onclick=\"window.open('http://" + window.location.hostname + ":" + st.port + "/')\"/>"; + + "

\" onclick=\"window.open('<%=url("admin/services/baidudrive/open")%>')\"/>"; } } }); diff --git a/luci-app-chatgpt-web/.github/workflows/main.yml b/luci-app-chatgpt-web/.github/workflows/main.yml deleted file mode 100644 index 3de4bc6d..00000000 --- a/luci-app-chatgpt-web/.github/workflows/main.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Build - -on: - push: - tags: - - v* - -jobs: - build: - name: Build ${{ matrix.arch }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - arch: - - - aarch64_generic - - i386_pentium-mmx - - loongarch64_generic - - mips_mips32 - - mipsel_24kc - - mipsel_mips32 - - x86_64 - - steps: - - uses: actions/checkout@main - with: - fetch-depth: 0 - - name: Building packages - uses: sbwml/openwrt-gh-action-sdk@go1.24 - env: - ARCH: ${{ matrix.arch }}-openwrt-24.10 - FEEDNAME: packages_ci - PACKAGES: luci-app-chatgpt-web - NO_REFRESH_CHECK: true - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.arch }} - path: bin/packages/${{ matrix.arch }}/packages_ci/*.ipk - - - name: Upload packages - uses: ncipollo/release-action@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - allowUpdates: true - replacesArtifacts: true - artifacts: "bin/packages/${{ matrix.arch }}/packages_ci/*.ipk" diff --git a/luci-app-chatgpt-web/Makefile b/luci-app-chatgpt-web/Makefile deleted file mode 100644 index b5cae97b..00000000 --- a/luci-app-chatgpt-web/Makefile +++ /dev/null @@ -1,22 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-only -# -# Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/luci-app-chatgpt-web -# -# This is free software, licensed under the Apache License, Version 2.0 . -# -include $(TOPDIR)/rules.mk - -NAME:=chatgpt-web -PKG_NAME:=luci-app-$(NAME) -PKG_VERSION:=1.2.1 -PKG_RELEASE:=16 - -LUCI_TITLE:=LuCI Support for chatgpt-web Client -LUCI_PKGARCH:=all - -define Package/$(PKG_NAME)/conffiles -/etc/config/chatgpt-web -endef -include $(TOPDIR)/feeds/luci/luci.mk - -# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-chatgpt-web/README.md b/luci-app-chatgpt-web/README.md deleted file mode 100644 index ab5de559..00000000 --- a/luci-app-chatgpt-web/README.md +++ /dev/null @@ -1,179 +0,0 @@ -## 访问数:![hello](https://views.whatilearened.today/views/github/sirpdboy/deplives.svg)[![](https://img.shields.io/badge/TG群-点击加入-FFFFFF.svg)](https://t.me/joinchat/AAAAAEpRF88NfOK5vBXGBQ) - - - -

-
luci-app-chatgpt-web
-

- -

-OpenWrt -Chrome -Safari -Firefox - GitHub release -Downloads -

- - -![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明1.jpg) - -请 **认真阅读完毕** 本页面,本页面包含注意事项和如何使用。 - -## 功能说明 - -luci-app-chatgpt Pure ChatGPT by OPENWRT based on OpenAI API - -luci-app-chatgpt 在OPENWRT上实现ChatGPT项目,基于OpenAI API - - - -- [luci-app-chatgpt](#功能说明) - - [特性](#特性) - - [编译方法](#编译方法) - - [使用方法](#使用方法) - - [自定义选项](#自定义选项)。 - - [说明](#说明) - - [界面](#界面) - - [捐助](#捐助) - - - -## 特性 - -- 无须第三依赖,直接使用官方API接口。 -- 支持复制/更新/刷新会话,语音输入,朗读等功能,以及众多[自定义选项](#自定义选项)。 -- 支持搜索会话,导入导出会话和设置,自定义头像,自定义系统角色等。 - -参考项目: -[markdown-it](https://github.com/markdown-it/markdown-it), -[highlight.js](https://github.com/highlightjs/highlight.js), -[github-markdown-css](https://github.com/sindresorhus/github-markdown-css), -[chatgpt-html](https://github.com/slippersheepig/chatgpt-html), -[markdown-it-copy](https://github.com/ReAlign/markdown-it-copy), -[markdown-it-texmath](https://github.com/goessner/markdown-it-texmath), -[awesome-chatgpt-prompts-zh](https://github.com/PlexPt/awesome-chatgpt-prompts-zh) - - -## 编译方法 - -- 将luci-app-chatgpt添加至 LEDE/OpenWRT 源码的方法。 - -### 下载源码方法: - - ```Brach - - # 下载源码 - - git clone https://github.com/sirpdboy/chatgpt-web.git package/luci-app-chatgpt - make menuconfig - - ``` -### 配置菜单 - - ```Brach - make menuconfig - # 找到 LuCI -> Applications, 选择 luci-app-chatgpt, 保存后退出。 - ``` - -### 编译 - - ```Brach - # 编译固件 - make package/luci-app-chatgpt/compile V=s - ``` - -## 使用方法 - - - 打开luci-app-chatgpt基本设置,填写API密钥、GPT模型、填写OpenAI接口、选择用户图像等。 - - - 可选GPT模型,默认gpt-3.5,当前使用gpt-4模型需通过openai的表单申请。 - - - OpenAI接口当本地可正常访问`api.openai.com`,填写`https://api.openai.com/` - - - 不可正常访问`api.openai.com`,填写其反代地址,注意:反代接口响应需添加跨域Header `Access-Control-Allow-Origin` - - - chatgpt-web页面就可以正常使用了,如果需要设置更多请看 自定义设置设置语音和系统角色等。 - -## 自定义选项 - -- 左边栏支持,清除对话,搜索会话,新建/重命名/删除(会话/文件夹)导出/导入/重置会话和设置数据,显示本地存储。 - -- 可选系统角色,默认不开启,有四个预设角色,后期会加入更多角色。 - -- 可选角色性格,默认灵活创新,对应接口文档的top_p参数。 - -- 可选回答质量,默认平衡,对应接口文档的temperature参数。 - -- 修改打字机速度,默认较快,值越大速度越快。 - -- 允许连续对话,默认开启,对话中包含上下文信息,会导致api费用增加。 - -- 允许长回复,默认关闭,**开启后可能导致api费用增加,并丢失大部分上下文,对于一些要发送`继续`才完整的回复,不用发`继续`了。** - -- 选择语音,默认Bing语音,支持Azure语音和系统语音,可分开设置提问语音和回答语音。 - -- 音量,默认最大。 - -- 语速,默认正常。 - -- 音调,默认正常。 - -- 允许连续朗读,默认开启,连续郎读到所有对话结束。 - -- 允许自动朗读,默认关闭,自动朗读新的回答。**(iOS需打开设置-自动播放视频预览,Mac上Safari需打开此网站的设置-允许全部自动播放)** - -- 支持语音输入,默认识别为普通话,可长按语音按钮修改识别选项。**语音识别必需条件:使用chrome内核系浏览器 + https网页或本地网页。** 如点击语音按钮没反应,可能是未授予麦克风权限或者没安装麦克风设备。 - - -## 说明 - --源码来源:https://github.com/sirpdboy/chatgpt-web --你可以随意使用其中的源码,但请注明出处。 - -![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明2.jpg) - -## 界面 - -![screenshots](./doc/chatgpt1.png) - -![screenshots](./doc/chatgpt2.png) - -![screenshots](./doc/chatgpt3.png) - - -## 使用与授权相关说明 - -- 本人开源的所有源码,任何引用需注明本处出处,如需修改二次发布必告之本人,未经许可不得做于任何商用用途。 - -# My other project - -- 路由安全看门狗 :https://github.com/sirpdboy/luci-app-watchdog -- 网络速度测试 :https://github.com/sirpdboy/luci-app-netspeedtest -- 计划任务插件(原定时设置) : https://github.com/sirpdboy/luci-app-taskplan -- 关机功能插件 : https://github.com/sirpdboy/luci-app-poweroffdevice -- opentopd主题 : https://github.com/sirpdboy/luci-theme-opentopd -- kucat酷猫主题: https://github.com/sirpdboy/luci-theme-kucat -- kucat酷猫主题设置工具: https://github.com/sirpdboy/luci-app-kucat-config -- NFT版上网时间控制插件: https://github.com/sirpdboy/luci-app-timecontrol -- 家长控制: https://github.com/sirpdboy/luci-theme-parentcontrol -- 定时限速: https://github.com/sirpdboy/luci-app-eqosplus -- 系统高级设置 : https://github.com/sirpdboy/luci-app-advanced -- ddns-go动态域名: https://github.com/sirpdboy/luci-app-ddns-go -- 进阶设置(系统高级设置+主题设置kucat/agron/opentopd): https://github.com/sirpdboy/luci-app-advancedplus -- 网络设置向导: https://github.com/sirpdboy/luci-app-netwizard -- 一键分区扩容: https://github.com/sirpdboy/luci-app-partexp -- lukcy大吉: https://github.com/sirpdboy/luci-app-lukcy - -## 捐助 - -![screenshots](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/说明3.jpg) - -| 图飞了😂 | 图飞了😂 | -| :-----------------: | :-------------: | -|![xm1](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/支付宝.png) | ![xm1](https://raw.githubusercontent.com/sirpdboy/openwrt/master/doc/微信.png) | - - - 图飞了😂 - - diff --git a/luci-app-chatgpt-web/doc/chatgpt1.png b/luci-app-chatgpt-web/doc/chatgpt1.png deleted file mode 100644 index f715b006..00000000 Binary files a/luci-app-chatgpt-web/doc/chatgpt1.png and /dev/null differ diff --git a/luci-app-chatgpt-web/doc/chatgpt2.png b/luci-app-chatgpt-web/doc/chatgpt2.png deleted file mode 100644 index 7de824a2..00000000 Binary files a/luci-app-chatgpt-web/doc/chatgpt2.png and /dev/null differ diff --git a/luci-app-chatgpt-web/doc/chatgpt3.png b/luci-app-chatgpt-web/doc/chatgpt3.png deleted file mode 100644 index 9d280941..00000000 Binary files a/luci-app-chatgpt-web/doc/chatgpt3.png and /dev/null differ diff --git a/luci-app-chatgpt-web/luasrc/controller/chatgpt-web.lua b/luci-app-chatgpt-web/luasrc/controller/chatgpt-web.lua deleted file mode 100644 index c100132c..00000000 --- a/luci-app-chatgpt-web/luasrc/controller/chatgpt-web.lua +++ /dev/null @@ -1,17 +0,0 @@ --- Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/luci-app-chatgpt-web --- Licensed to the public under the Apache License 2.0. - -module("luci.controller.chatgpt-web", package.seeall) - -function index() - if not nixio.fs.access("/etc/config/chatgpt-web") then - return - end - - local e = entry({"admin", "services", "chatgpt-web"}, alias("admin", "services", "chatgpt-web", "setting"),_("Chatgpt Web"), 20) - e.dependent=false - e.acl_depends = { "luci-app-chatgpt-web" } - entry({"admin", "services", "chatgpt-web", "setting"}, cbi("chatgpt-web"), _("Base Setting"), 10).leaf=true - entry({"admin", "services", "chatgpt-web", "chatgpt-web"}, cbi("chatgpt",{hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("Chatgpt Web"), 30).leaf=true -end - diff --git a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt-web.lua b/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt-web.lua deleted file mode 100644 index 38f5e7da..00000000 --- a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt-web.lua +++ /dev/null @@ -1,36 +0,0 @@ --- Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/https://github.com/sirpdboy/luci-app-chatgpt-web - -local m, s ,o - -m = Map("chatgpt-web") -m.title = translate("Chatgpt Web") -m.description = translate("ChatGPT-Web is the version used on OPENWRT of OPENAI")..translate("
For specific usage, see:")..translate("GitHub @sirpdboy/chatgpt-web\ ") - -s = m:section(TypedSection, "basic", translate("Base Setting")) -s.addremove = false -s.anonymous = true - - -o = s:option(Value,"userpic",translate("Character images")) -o:value("girl.jpg",translate("girl")) -o:value("girl2.jpg",translate("girl2")) -o:value("girl3.jpg",translate("girl3")) -o:value("boy.jpg",translate("boy")) -o:value("boy2.jpg",translate("boy2")) -o:value("boy3.jpg",translate("boy3")) -o:value("dog.jpg",translate("dog")) -o:value("cartoon.jpg",translate("cartoon")) -o:value("cartoonboy.jpg",translate("cartoonboy")) -o:value("cartoonboy2.jpg",translate("cartoonboy2")) -o:value("cartoongirl.jpg",translate("cartoongirl")) -o:value("cartoongirl2.jpg",translate("cartoongirl2")) -o.rmempty = true -o.default="girl.jpg" - - -o=s:option(TextValue, "systemrole" ,translate("Default Role Settings")) -o.rows = 5 -o.rmempty=false - - -return m diff --git a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt.lua b/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt.lua deleted file mode 100644 index 162de6d0..00000000 --- a/luci-app-chatgpt-web/luasrc/model/cbi/chatgpt.lua +++ /dev/null @@ -1,9 +0,0 @@ --- Copyright (C) 2023-2025 sirpdboy https://github.com/sirpdboy/luci-app-chatgpt-web -require("luci.util") -local m, s - -m = Map("chatgpt-web",translate("")) - -m:section(SimpleSection).template = "chatgpt-web" - -return m diff --git a/luci-app-chatgpt-web/luasrc/view/chatgpt-web.htm b/luci-app-chatgpt-web/luasrc/view/chatgpt-web.htm deleted file mode 100644 index 078e5ac7..00000000 --- a/luci-app-chatgpt-web/luasrc/view/chatgpt-web.htm +++ /dev/null @@ -1,6845 +0,0 @@ -<%# - Copyright 2023-2025 sirpdboy Wich https://github.com/sirpdboy/luci-app-chatgpt-web - Licensed to the public under the Apache License 2.0. --%> -<% -local fs = require "nixio.fs" -local dsp = require "luci.dispatcher" -local uci = luci.model.uci.cursor() -local apikeys = uci:get_first('chatgpt-web', 'basic', 'apikey') -local userpic = "/luci-static/chatgpt-web/user/" .. uci:get_first('chatgpt-web', 'basic', 'userpic') -local sysrole = uci:get_first('chatgpt-web', 'basic', 'systemrole') - --%> - -
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
ChatGPT
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
- - 4o - - -
-
-
- - -
- - -
-
-
-
- -
-
-
-
- - -
- - -
-
-
-
-
-
- - - -
-
-
-
-
- - - -
-
- - - -
-
-
-
-
-
ChatGPT
-
DeepSeek
-
Azure
-
Gemini
-
Claude
-
-
- -
-
-
-
ChatGPT
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
- - - - -
- - - -
-
-
-
- - - - - - - - - - - - - - - - -
\ No newline at end of file diff --git a/luci-app-chatgpt-web/po/zh-cn/chatgpt-web.po b/luci-app-chatgpt-web/po/zh-cn/chatgpt-web.po deleted file mode 100644 index 91654dbd..00000000 --- a/luci-app-chatgpt-web/po/zh-cn/chatgpt-web.po +++ /dev/null @@ -1,71 +0,0 @@ -msgid "" -msgstr "Content-Type: text/plain; charset=UTF-8" - -msgid "chatgpt-web" -msgstr "ChatGPT-Web" - -msgid "ChatGPT-Web is the version used on OPENWRT of OPENAI" -msgstr "ChatGPT-Web是OPENAI的OPENWRT上使用的WEB版本,不需要第三方依赖,绿色安全直接使用OPENAI的API接口" - -msgid "Base Setting" -msgstr "基本设置" - -msgid "GPT Model" -msgstr "GPT模型" - -msgid "Character images" -msgstr "人物图像" - -msgid "boy" -msgstr "男生" - -msgid "boy2" -msgstr "男生2" - -msgid "boy3" -msgstr "男生3" - -msgid "girl" -msgstr "女生" - -msgid "girl2" -msgstr "女生2" - -msgid "girl3" -msgstr "女生3" - -msgid "dog" -msgstr "狗" - -msgid "cartoon" -msgstr "卡通图像" - -msgid "cartoonboy" -msgstr "卡通男孩" - -msgid "cartoonboy2" -msgstr "卡通小男孩" - -msgid "cartoongirl" -msgstr "卡通女生" - -msgid "cartoongirl2" -msgstr "卡通女生2" - -msgid "OpenAI interface" -msgstr "OpenAI接口" - -msgid "API Key" -msgstr "API密钥" - -msgid "Enter your own API key, and if you do not have one, enter the official website API to generate it:" -msgstr "输入自己的API密钥(sk-开头),没有的进官网API生成:" - -msgid "Click to open" -msgstr "点击前往" - -msgid "Default Role Settings" -msgstr "默认角色设置" - -msgid "
For specific usage, see:" -msgstr "
具体使用方法参见:" diff --git a/luci-app-chatgpt-web/po/zh_Hans/chatgpt-web.po b/luci-app-chatgpt-web/po/zh_Hans/chatgpt-web.po deleted file mode 100644 index 91654dbd..00000000 --- a/luci-app-chatgpt-web/po/zh_Hans/chatgpt-web.po +++ /dev/null @@ -1,71 +0,0 @@ -msgid "" -msgstr "Content-Type: text/plain; charset=UTF-8" - -msgid "chatgpt-web" -msgstr "ChatGPT-Web" - -msgid "ChatGPT-Web is the version used on OPENWRT of OPENAI" -msgstr "ChatGPT-Web是OPENAI的OPENWRT上使用的WEB版本,不需要第三方依赖,绿色安全直接使用OPENAI的API接口" - -msgid "Base Setting" -msgstr "基本设置" - -msgid "GPT Model" -msgstr "GPT模型" - -msgid "Character images" -msgstr "人物图像" - -msgid "boy" -msgstr "男生" - -msgid "boy2" -msgstr "男生2" - -msgid "boy3" -msgstr "男生3" - -msgid "girl" -msgstr "女生" - -msgid "girl2" -msgstr "女生2" - -msgid "girl3" -msgstr "女生3" - -msgid "dog" -msgstr "狗" - -msgid "cartoon" -msgstr "卡通图像" - -msgid "cartoonboy" -msgstr "卡通男孩" - -msgid "cartoonboy2" -msgstr "卡通小男孩" - -msgid "cartoongirl" -msgstr "卡通女生" - -msgid "cartoongirl2" -msgstr "卡通女生2" - -msgid "OpenAI interface" -msgstr "OpenAI接口" - -msgid "API Key" -msgstr "API密钥" - -msgid "Enter your own API key, and if you do not have one, enter the official website API to generate it:" -msgstr "输入自己的API密钥(sk-开头),没有的进官网API生成:" - -msgid "Click to open" -msgstr "点击前往" - -msgid "Default Role Settings" -msgstr "默认角色设置" - -msgid "
For specific usage, see:" -msgstr "
具体使用方法参见:" diff --git a/luci-app-chatgpt-web/root/etc/config/chatgpt-web b/luci-app-chatgpt-web/root/etc/config/chatgpt-web deleted file mode 100644 index a7a1636a..00000000 --- a/luci-app-chatgpt-web/root/etc/config/chatgpt-web +++ /dev/null @@ -1,8 +0,0 @@ - -config basic - option apiHost 'https://api.openai.com/' - option modelver 'gpt-3.5-turbo' - option userpic 'girl.jpg' - option apikey '' - option systemrole '你是一个乐于助人的专业助手,尽量简明扼要地回答' - diff --git a/luci-app-chatgpt-web/root/usr/share/rpcd/acl.d/luci-app-chatgpt-web.json b/luci-app-chatgpt-web/root/usr/share/rpcd/acl.d/luci-app-chatgpt-web.json deleted file mode 100644 index c8c06f50..00000000 --- a/luci-app-chatgpt-web/root/usr/share/rpcd/acl.d/luci-app-chatgpt-web.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "luci-app-chatgpt-web": { - "description": "Grant UCI access for luci-app-chatgpt-web", - "read": { - "uci": [ "chatgpt-web" ] - }, - "write": { - "uci": [ "chatgpt-web" ] - } - } -} diff --git a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/11.11.1github.min.css b/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/11.11.1github.min.css deleted file mode 100644 index 275239a7..00000000 --- a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/11.11.1github.min.css +++ /dev/null @@ -1,10 +0,0 @@ -pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! - Theme: GitHub - Description: Light theme as seen on github.com - Author: github.com - Maintainer: @Hirse - Updated: 2021-05-15 - - Outdated base version: https://github.com/primer/github-syntax-light - Current colors taken from GitHub's CSS -*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} \ No newline at end of file diff --git a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/chatgpt-web.css b/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/chatgpt-web.css deleted file mode 100644 index 7a24e376..00000000 --- a/luci-app-chatgpt-web/root/www/luci-static/chatgpt-web/chatgpt-web.css +++ /dev/null @@ -1,2166 +0,0 @@ - - .chat_window { - display: flex; - width: 100%; - max-width: 1920px; - height: 888px; - border-radius: 8px; - background-color: rgba(255,255,255,0.5); - overflow: auto; - } - - .chat_window>.overlay { - width: 100%; - height: 100%; - position: absolute; - left: 0; - top: 0; - background-color: rgba(0, 0, 0, .3); - z-index: 90; - opacity: 0; - visibility: hidden; - cursor: pointer; - transition: all 120ms ease-in-out; - } - - .chat_window .mainContent .cbi-input-invalid { - color: var(--title-color); - border-bottom-color: var(--title-color); - } - - - @media screen and (min-width: 800px) { - - .mainContent { - width: calc(100% - 250px); - } - - .chat_window>.nav { - position: relative; - margin-left: -250px; - transition: margin-left 250ms; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - } - - .show-nav .nav { - margin-left: 0; - } - - #sysDialog { - max-width: 600px; - } - - .sysContent { - display: flex; - } - - .sysSwitch { - flex-shrink: 0; - width: 160px; - } - - .sysSwitch>div { - padding-left: 6px; - } - - .sysDetail { - margin-left: 12px; - margin-top: 5px; - } - } - - @media screen and (max-width: 800px) { - - .mainContent { - width: 100%; - } - - .chat_window>.nav { - position: absolute !important; - left: -250px; - transition: left 250ms; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - } - - .show-nav .nav { - left: 0; - box-shadow: rgba(0, 0, 0, 0.2) 0px 8px 10px -5px, rgba(0, 0, 0, 0.14) 0px 16px 24px 2px, rgba(0, 0, 0, 0.12) 0px 6px 30px 5px; - } - - .show-nav .overlay { - visibility: visible; - opacity: 1; - } - - #sysDialog { - max-width: 400px; - } - - .sysSwitch { - display: flex; - } - - .sysSwitch>div { - width: 50%; - justify-content: center; - } - - .sysDetail { - margin-top: 8px; - } - } - .full_window { - max-width: none; - max-height: none; - } - - .chat_window>.nav { - width: 250px; - background-color: rgba(var(--primary-rgbbody),1); - color: var(--body-color); - top: 0; - z-index: 99; - flex-shrink: 0; - display: flex; - flex-direction: column; - margin-left: -250px; - transition: left 120ms ease-in-out; - } - - .mainContent { - height: 100%; - position: relative; - width: 100%; - display: flex; - flex-direction: column; - flex: 1; - } - - .show-nav .nav { - margin-left: 0; - } - .show-nav .chatnav { - margin-left: 0; - box-shadow: rgba(0, 0, 0, 0.2) 0px 8px 10px -5px, rgba(0, 0, 0, 0.14) 0px 16px 24px 2px, rgba(0, 0, 0, 0.12) 0px 6px 30px 5px; - } - - .show-nav .overlay { - opacity: 1; - visibility: visible; - } - .top_menu { - width: 100%; - background-color: rgba(255,255,255,0.5); - height: 50px; - padding: 5px 0; - } - - .top_menu .toggler { - margin-left: 10px; - width: 40px; - height: 40px; - float: left; - padding: 5px 7px; - border-radius: 4px; - cursor: pointer; - -webkit-tap-highlight-color: transparent; - } - - .top_menu .toggler:hover { - background: #e0e0e0; - } - - .top_menu .toggler .button { - width: 26px; - height: 4px; - border-radius: 4px; - position: absolute; - pointer-events: none; - } - - .top_menu .toggler .button.close { - margin-top: 3px; - background-color: #000; - } - - .top_menu .toggler .button.minimize { - margin-top: 13px; - background-color: #000; - } - - .top_menu .toggler .button.maximize { - margin-top: 23px; - background-color: #000; - } - - .top_menu .title { - font-size: 20px; - height: 40px; - line-height: 40px; - position: relative; - pointer-events: none; - } - - #selector { - position: absolute; - top: 50%; - left: 50%; - padding: 0 4px 0 10px; - cursor: pointer; - font-size: 20px; - transform: translate(-50%, -50%); - pointer-events: auto; - border-radius: 4px; - -webkit-user-select: none; - user-select: none; - display: flex; - align-items: center; - } - - #selector:hover { - color: var(--title-color); - } - - #modelVer { - margin-left: 6px; - margin-right: 4px; - height: 40px; - line-height: 38px; - } - - .messages { - position: relative; - flex: 1; - overflow-x: hidden; - overflow-y: auto; - font-size: 16px; - width: 100%; - text-align: center; - } - - .messages::-webkit-scrollbar, - - #chatlog .markdown-body>pre::-webkit-scrollbar, - #chatlog .markdown-body>pre>code::-webkit-scrollbar, - #chatlog .responseBody pre>code::-webkit-scrollbar, - #setDialog::-webkit-scrollbar, - .allList::-webkit-scrollbar, - .sysDetail::-webkit-scrollbar, - #apiSelect::-webkit-scrollbar { - width: 10px; - height: 10px; - } - - .messages::-webkit-scrollbar-track, - - #chatlog .markdown-body>pre::-webkit-scrollbar-track, - #chatlog .markdown-body>pre>code::-webkit-scrollbar-track, - #chatlog .responseBody pre>code::-webkit-scrollbar-track, - #setDialog::-webkit-scrollbar-track, - .allList::-webkit-scrollbar-track, - .sysDetail::-webkit-scrollbar-track, - #apiSelect::-webkit-scrollbar-track { - background-clip: padding-box; - background: transparent; - border: none; - } - - .messages::-webkit-scrollbar-corner, - - #chatlog .markdown-body>pre::-webkit-scrollbar-corner, - #chatlog .markdown-body>pre>code::-webkit-scrollbar-corner, - #chatlog .responseBody pre>code::-webkit-scrollbar-corner, - #setDialog::-webkit-scrollbar-corner, - .allList::-webkit-scrollbar-corner, - .sysDetail::-webkit-scrollbar-corner, - #apiSelect::-webkit-scrollbar-corner { - background-color: transparent; - } - - .messages::-webkit-scrollbar-thumb, - - #chatlog .markdown-body>pre::-webkit-scrollbar-thumb, - #chatlog .markdown-body>pre>code::-webkit-scrollbar-thumb, - #chatlog .responseBody pre>code::-webkit-scrollbar-thumb, - #setDialog::-webkit-scrollbar-thumb, - .allList::-webkit-scrollbar-thumb, - .sysDetail::-webkit-scrollbar-thumb, - #apiSelect::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.1); - background-clip: padding-box; - border: solid transparent; - border-radius: 10px; - } - - .messages::-webkit-scrollbar-thumb:hover, - - #chatlog .markdown-body>pre::-webkit-scrollbar-thumb:hover, - #chatlog .markdown-body>pre>code::-webkit-scrollbar-thumb:hover, - #chatlog .responseBody pre>code::-webkit-scrollbar-thumb:hover, - #setDialog::-webkit-scrollbar-thumb:hover, - .allList::-webkit-scrollbar-thumb:hover, - .sysDetail::-webkit-scrollbar-thumb:hover, - #apiSelect::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0.4); - } - - #chatlog { - word-wrap: break-word; - text-align: start; - } - - #chatlog>div { - padding: 18px 40px 18px 54px; - } - .chatAvatar { - position: absolute; - width: 30px; - height: 30px; - border-radius: 2px; - } - - .request>.chatAvatar { - top: 18px; - left: 13px; - margin-top: 15px; - margin-bottom: 15px; - } - .response>.chatAvatar { - background: #10a37f; - top: 18px; - left: 13px; - margin-top: 15px; - margin-bottom: 15px; - } - - .gptAvatar>svg { - margin-top: 3px; - margin-left: 3px; - } - - .chatAvatar>img { - display: block; - width: 100%; - height: 100%; - border-radius: 2px; - } - - .modelAvatar>svg, - .chatAvatar>svg { - display: block; - margin-top: 4px; - margin-left: 4px; - } - - #chatlog .request { - position: relative; - background-color: var(--inputborder-color); - display: flex; - justify-content: center; - } - - - .response .responseBody { - display: flex; - justify-content: flex-end; - margin: 18px 45px 18px 20px; - font-size: 15px; - - } - .requestBody { - white-space: pre-wrap; - margin: 18px; - flex: 1; - } - - #chatlog .response { - background-color: var(--inputborder-color); - position: relative; - } - - .response .markdown-body { - margin: 18px 65px 18px 0px; - flex: 1; - font-size: 15px; - background-color: rgba(255,255,255,0)!important; - - } - - #chatlog .markdown-body a { - padding: 0 1px 0 2px; - } - - #chatlog .markdown-body pre { - overflow-x: auto; - padding: 10px; - position: relative; - white-space: pre-wrap; - background-color: var(--inputborder-color)!important; - - } - - #chatlog .markdown-body .mermaid { - margin-bottom: 8px; - padding: 0 10px 0 10px; - position: relative; - border-radius: 6px; - } - - .mermaid>svg { - cursor: pointer; - max-height: 600px; - display: block; - } - - #chatlog .markdown-body pre>code { - overflow-x: auto; - white-space: pre-wrap; - display: block; - } - - #chatlog .markdown-body ul { - list-style-type: disc; - } - - .m-mdic-copy-wrapper { - position: absolute; - top: 5px; - right: 16px; - -webkit-user-select: none; - user-select: none; - } - - .m-mdic-copy-wrapper span.u-mdic-copy-code_lang { - position: absolute; - top: 3px; - right: calc(100% + 4px); - font-family: system-ui; - font-size: 12px; - line-height: 18px; - color: #bbb; - } - - .m-mdic-copy-wrapper div.u-mdic-copy-notify { - position: absolute; - top: 0; - right: 0; - padding: 3px 6px; - border: 0; - border-radius: 3px; - background: none; - font-family: system-ui; - font-size: 12px; - line-height: 18px; - color: #555; - outline: none; - right: 100%; - padding-right: 4px; - } - - .m-mdic-copy-wrapper button.u-mdic-copy-btn { - position: relative; - top: 0; - right: 0; - padding: 3px 6px; - border: 0; - border-radius: 3px; - background: none; - font-family: system-ui; - font-size: 12px; - line-height: 18px; - color: #bbb; - outline: none; - cursor: pointer; - transition: color 0.2s; - } - - .m-mdic-copy-wrapper span.u-mdic-copy-code_lang::before, - .m-mdic-copy-wrapper div.u-mdic-copy-notify::before, - .m-mdic-copy-wrapper button.u-mdic-copy-btn::before { - content: attr(text); - } - - .m-mdic-copy-wrapper button.u-mdic-copy-btn:hover { - color: #555; - } - - #stopChat { - display: none; - margin: 0 auto; - margin-top: 3px; - width: 80px; - height: 32px; - text-align: center; - line-height: 32px; - color: white; - background: #f8b26a; - cursor: pointer; - border-radius: 3px; - position: sticky; - bottom: 2px; - justify-content: center; - align-items: center; - } - - #stopChat>svg { - margin-right: 8px; - } - - #stopChat:hover { - background: #f0aa60; - } - - .bottom_wrapper { - position: relative; - width: 100%; - padding: 10px 10px; - max-width: 91%; - margin: 0 auto; - } - - .bottom_wrapper .message_input_wrapper { - width: calc(100% - 134px); - position: relative; - text-align: left; - } - - .bottom_wrapper .message_input_wrapper .message_input_text { - border-radius: 4px; - border: none; - outline: none; - resize: none; - background-color: var(--inputborder-color); - height: 47px; - font-size: 16px; - max-height: 200px; - padding: 13px 0 13px 13px; - width: 100%!important; - display: block; - transition: background-color 250ms; - } - - .bottom_wrapper .message_input_wrapper .message_input_text:focus { - background-color: var(--inputborder-color); - } - - .bottom_wrapper .message_input_wrapper .message_input_text::-webkit-scrollbar { - display: none; - width: 0; - height: 0; - } - - #sendbutton { - width: 70px; - height: 47px; - font-size: 18px; - font-weight: bold; - color: #fff; - background-color: #7ac516; - padding: 0.5rem 0.5rem; - transition: all 0.2s linear; - position: absolute; - right: 65px; - bottom: 10px; - cursor: not-allowed; - } - - .activeSendBtn { - background-color: #99c959 !important; - cursor: pointer !important; - } - - .activeSendBtn:hover { - background-color: #90c050 !important; - } - - .clearConv { - position: absolute; - right: 10px; - bottom: 10px; - width: 47px; - padding: 0.5rem 0.5rem; - height: 47px; - } - - .clearConv>svg { - margin: 0 auto; - top: 50%; - left: 50%; - } - - .clearConv:hover { - background-color: var(--body-hover-bgcolor); - } - - .clearConv svg:first-child { - display: none; - } - - .clearConv svg:nth-child(2) { - display: block; - } - - .closeConv { - background: #e0e0e0;; - } - - .closeConv:hover { - background: #e8e8e8; - } - - .closeConv svg:first-child { - display: block; - } - - .closeConv svg:nth-child(2) { - display: none; - } - - .loaded>span { - display: inline-block; - } - - .loaded>svg { - display: none; - } - - .loading { - background: rgba(0,0,0,0) !important; - } - - .loading>span { - display: none; - } - - .loading>svg { - display: block; - } - - .switch-slide { - display: inline-block; - vertical-align: middle; - } - - .switch-slide-label { - display: block; - width: 38px; - height: 18px; - background: #909090; - border-radius: 30px; - cursor: pointer; - position: relative; - -webkit-transition: 0.3s ease; - transition: 0.3s ease; - } - - .switch-slide-label:after { - content: ""; - display: block; - width: 16px; - height: 16px; - border-radius: 100%; - background: #fff; - box-shadow: 0 1px 1px rgba(0, 0, 0, .1); - position: absolute; - left: 1px; - top: 1px; - -webkit-transform: translateZ(0); - transform: translateZ(0); - -webkit-transition: all 250ms; - transition: all 250ms; - } - - .switch-slide input:checked+label { - background: #99c959; - -webkit-transition: all 250ms; - transition: all 250ms; - } - - .switch-slide input:checked+label:after { - left: 21px; - } - - .settings { - padding: 0px 7px; - margin-top: -40px; - float: right; - border-radius: 4px; - cursor: pointer; - top: 5px; - } - - - .setBtn { - margin-left: 2px; - cursor: pointer; - padding: 5px; - border: none; - border-radius: 4px; - } - - .setBtn>svg { - display: block; - color: #909090; - } - - .setBtn:hover { - background: #e0e0e0;; - } - - #setting { - right: 15px; - } - - #toggleFull { - right: 56px; - } - - #toggleLight *, - #toggleFull * { - pointer-events: none; - } - - .showSetting { - background-color: var(--inputborder-color); - } - - .showModels { - background-color: var(--inputborder-color); - } - - .showModels>svg:last-child { - transform: scaleY(-1); - } - - #modelDialog { - position: absolute; - z-index: 2; - background: rgba(var(--primary-rgbbody), 1); - width: 240px; - left: 50%; - transform: translateX(-50%); - top: 46px; - overflow-y: auto; - max-height: calc(100% - 55px); - -webkit-user-select: none; - user-select: none; - border-radius: 4px; - padding: 8px 5px 8px 5px; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); - } - - .modelSingle { - height: 40px; - border-radius: 3px; - cursor: pointer; - line-height: 40px; - display: flex; - align-items: center; - position: relative; - } - - .modelSingle * { - pointer-events: none; - } - - .modelSingle:hover { - background-color: var(--body-hover-bgcolor); - } - - .activeModel::before { - content: ""; - position: absolute; - right: 15px; - top: 7px; - width: 8px; - height: 16px; - border-style: solid; - border-width: 0 4px 4px 0; - transform: rotate(45deg); - } - - .modelAvatar { - margin-left: 6px; - margin-right: 10px; - width: 30px; - flex-shrink: 0; - border-radius: 2px; - height: 30px; - } - - #setDialog { - position: absolute; - z-index: 2; - background-color: rgba(var(--primary-rgbbody),1); - width: 320px; - right: 6px; - top: 46px; - overflow-y: auto; - max-height: calc(100% - 55px); - -webkit-user-select: none; - user-select: none; - border-radius: 5px; - padding: 8px 12px 8px 12px; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); - } - - #setDialog input { - width: 100%; - } - - #setDialog .inlineTitle { - display: inline-block; - width: 80px; - line-height: 16px; - vertical-align: middle; - } - - #convOption, - #speechOption, - #speechDetail, - #recOption { - margin-bottom: 6px; - } - - #convOption>div, - #speechOption>div, - #speechDetail>div, - #recOption div { - margin-top: 10px; - } - - #voiceRecSetting select, #speechDetail select { - color: var(--title-color); - - } - .inputTextClass { - outline: none; - border-radius: 2px; - margin-top: 3px; - height: 32px; - font-size: 15px; - padding-left: 6px; - border: none; - } - - .areaTextClass { - width: 100%; - height: 150px; - white-space: normal; - resize: none; - padding: 6px; - } - - input[type="range"] { - -webkit-appearance: none; - appearance: none; - display: block; - margin: 4px 0 3px 0; - height: 8px; - background: #909090; - border-radius: 5px; - background-image: linear-gradient(#99c959, #99c959); - background-size: 100% 100%; - background-repeat: no-repeat; - } - - input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - height: 15px; - width: 15px; - border-radius: 50%; - background: #99c959; - cursor: ew-resize; - } - - input[type=range]::-webkit-slider-runnable-track { - -webkit-appearance: none; - box-shadow: none; - border: none; - background: transparent; - } - - .justSetLine { - display: flex; - justify-content: space-between; - align-items: center; - } - - .justSetBtn { - height: 32px; - border-radius: 3px; - line-height: 32px; - background-color: var(--inputborder-color); - text-align: center; - padding: 0px 8px; - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - } - - .justSetBtn:hover { - background-color: #d0d0d0; - } - - .justSetBtn>svg { - margin-right: 3px; - } - - .readyTestVoice>div:not(:first-child) { - display: none; - } - - .pauseTestVoice>div:nth-child(1), - .pauseTestVoice>div:nth-child(3) { - display: none; - } - - .resumeTestVoice>div:nth-child(1), - .resumeTestVoice>div:nth-child(2) { - display: none; - } - - .presetSelect>div { - display: inline-block; - } - - .presetSelect select { - outline: none; - border-radius: 3px; - width: 120px; - border-color: rgba(0, 0, 0, .3); - } - - .selectDef { - display: flex; - justify-content: space-between; - font-size: 13px; - color: #909090; - } - - #preSetSpeech { - width: 100%; - outline: none; - height: 30px; - line-height: 30px; - font-size: 14px; - margin-top: 5px; - border-radius: 3px; - border-color: rgba(0, 0, 0, .3); - } - - .mdOption { - position: absolute; - right: 0px; - top: 0; - width: 50px; - height: 100%; - pointer-events: none; - } - - .mdOption>div { - pointer-events: auto; - cursor: pointer; - } - - .mdOption svg * { - pointer-events: none; - } - - .refreshReq svg:not(:first-child) { - display: none; - } - - .halfRefReq svg:not(:nth-child(2)) { - display: none; - } - - .optionItems { - position: absolute; - bottom: -5px; - display: flex; - justify-content: space-between; - visibility: visible; - z-index: 1; - color: #808080; - } - - .optionItems:hover { - visibility: visible; - } - - .request:hover .optionItems, - .request:hover .voiceCls, - .response:hover .optionItems, - .response:hover .voiceCls { - visibility: visible; - } - - .optionItem { - border-radius: 10px; - height: 24px; - width: 32px; - border: 1px solid #e0e0e0;; - background-color: #f7f7f8; ; - display: flex !important; - justify-content: center; - align-items: center; - } - - .optionItem * { - pointer-events: none; - } - - .optionItem:hover { - background: #e0e0e0;; - } - - .voiceCls { - position: relative; - height: 100%; - visibility: visible; - display: flex; - align-items: center; - } - - .voiceCls>svg { - color: #c0c0c0; - display: block; - margin-left: 5px; - position: relative; - pointer-events: none; - } - - .voiceCls:hover>svg { - color: #808080; - } - - .showVoiceCls, - .showVoiceCls .markdown-body { - background: #e0e0e0 !important; - } - - .showVoiceCls .voiceCls { - visibility: visible !important; - } - - .showEditReq { - position: sticky !important; - top: 0; - bottom: 0; - z-index: 1; - } - - .showEditReq, - .showEditReq .markdown-body { - background: #e0e0e0 !important; - } - - .readyVoice svg:not(:first-child) { - display: none; - } - - .pauseVoice svg:not(:nth-child(2)) { - display: none; - } - - .resumeVoice svg:not(:nth-child(3)) { - display: none; - } - - #voiceTypes>span { - border-radius: 3px; - margin-left: 4px; - cursor: pointer; - color: var(--title-color); - padding: 1px 5px; - } - - #voiceTypes>span:hover { - background-color: var(--body-hover-bgcolor); - } - - .selVoiceType { - background-color: var(--inputborder-color); - } - - .navHeader { - width: 100%; - padding: 5px 10px; - display: flex; - justify-content: space-between; - } - - #newChat { - text-align: center; - width: 80%; - height: 40px; - border-radius: 3px; - background: var(--inputborder-color); - display: flex; - align-items: center; - justify-content: center; - -webkit-user-select: none; - user-select: none; - cursor: pointer; - flex: 1; - } - - .navHeader>div:hover { - background: var(--body-hover-bgcolor)!important; - } - - #newChat>svg { - margin-right: 2px; - } - - #newFolder { - height: 40px; - width: 40px; - margin-left: 10px; - border-radius: 3px; - background: var(--inputborder-color); - cursor: pointer; - position: relative; - -webkit-user-select: none; - user-select: none; - } - - #newFolder>svg { - display: block; - margin: 8px auto; - } - - .extraChat { - padding: 2px 10px 6px 10px; - position: relative; - } - - #searchChat { - width: 100%; - height: 36px; - padding-left: 10px; - padding-right: 16px; - font-size: 16px; - outline: none; - border: none; - background: var(--inputborder-color); - border-radius: 3px; - } - - #searchChat:focus { - background: var(--inputborder-color); - } - - #searchChat:placeholder-shown+#clearSearch { - display: none; - } - - #clearSearch { - position: absolute; - right: 12px; - top: 8px; - cursor: pointer; - } - - #clearSearch:hover { - color: var(--title-color); - } - - #clearSearch>svg { - display: block; - } - - .seledSearch { - background: var(--inputborder-color)!important; - } - - #matchCaseSearch { - position: absolute; - right: 12px; - top: 8px; - cursor: pointer; - border-radius: 3px; - color: var(--inputborder-color); - } - - #matchCaseSearch:hover { - background: var(--body-hover-bgcolor); - } - - #matchCaseSearch>svg { - display: block; - } - - .navFooter { - padding-bottom: 8px; - background: var(--inputborder-color); - } - - .navFooter .divider { - width: 100%; - border-top: 1px solid var(--inputborder-color); - margin: 4px 0; - } - - .navFunc { - padding-top: 5px; - display: flex; - justify-content: space-evenly; - } - - .navFunc svg { - display: block; - } - - .navFunc>div, - .navFunc>label { - border-radius: 20px; - text-align: center; - padding: 8px 8px; - font-size: 14px; - cursor: pointer; - } - - .navFunc>div:hover, - .navFunc>label:hover { - background: var(--body-hover-bgcolor); - } - - .navFooter .links { - text-align: center; - -webkit-user-select: none; - user-select: none; - } - - .navFooter .links a { - text-decoration: none; - } - - - .allList { - width: 100%; - flex: 1; - overflow-y: auto; - } - - #chatList { - min-height: 50px; - } - - .dragingLi { - filter: brightness(90%); - } - - .dragingChat { - background: #e8e8e8; - } - - .expandFolder>.headLi>svg { - transform: rotate(90deg); - } - - .expandFolder>.chatsInFolder { - display: block; - } - - .chatsInFolder { - display: none; - margin-left: 22px; - padding-left: 2px; - border-left: 1px solid #909090; ; - } - - .headLi, - .chatLi { - cursor: pointer; - width: 100%; - height: 50px; - display: flex; - justify-content: space-between; - align-items: center; - position: relative; - } - - .headLi *, - .chatLi * { - pointer-events: none; - } - - .headLi>svg, - .chatLi>svg { - margin-left: 10px; - pointer-events: none; - } - - .folderOption svg, - .chatOption svg { - pointer-events: auto; - } - - .headLi svg *, - .chatLi svg * { - pointer-events: none; - } - - .headLi .folderInfo { - position: absolute; - left: 40px; - height: 40px; - max-width: calc(100% - 115px); - } - - .chatLi .chatInfo { - position: absolute; - left: 45px; - height: 40px; - max-width: calc(100% - 100px); - } - - .folderInfo *, - .chatInfo * { - -webkit-user-select: none; - user-select: none; - } - - .chatInfo span { - background: var(--inputborder-color)!important; - } - - .headLi .folderName, - .chatLi .chatName { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - line-height: 20px; - height: 20px; - } - - .headLi .folderNum, - .chatLi .chatPre { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - font-size: 12px; - line-height: 20px; - height: 20px; - } - - .headLi .folderOption, - .chatLi .chatOption { - visibility: hidden; - display: flex; - margin-right: 5px; - } - - .folderLi .chatLi>svg { - margin-left: 5px; - } - - .folderLi .chatLi .chatInfo { - left: 35px; - max-width: calc(100% - 85px); - } - - .folderLi .chatLi #activeChatEdit { - left: 32px; - width: calc(100% - 60px) - } - - .folderLi:hover { - background: var(--body-hover-bgcolor)!important; - } - - .chatLi:hover { - background: var(--body-hover-bgcolor)!important; - } - - .headLi:hover .folderOption, - .chatLi:hover .chatOption { - visibility: visible !important; - } - - .activeFolder, - .activeChatLi { - background: var(--inputborder-color)!important; - } - - .activeChatLi .chatOption { - visibility: visible !important; - } - - .folderOption>svg:hover, - .chatOption>svg:hover { - color: var(--title-color); - } - - #activeChatEdit { - position: absolute; - left: 42px; - font-size: 16px; - border-radius: 2px; - color: var(--title-color); - background-color: rgba(var(--primary-rgbbody),1); - outline: none; - border: none; - pointer-events: auto; - height: 24px; - line-height: 24px; - width: calc(100% - 75px); - padding: 20px 3px; - z-index: 1; - } - - #loadMask { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 100; - background-color: #edeff2; - } - - #loadMask>div { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - text-align: center; - } - - @keyframes loading { - - 0%, - 100% { - transform: scale(0); - } - - 50% { - transform: scale(1); - } - } - - .loadingCSSIcon { - position: relative; - display: flex; - align-items: center; - justify-content: space-evenly; - } - - .loadingCSSIcon div { - width: 15%; - height: 0; - padding-bottom: 15%; - border-radius: 50%; - animation: loading 1s cubic-bezier(0.3, 0, 0.7, 1) infinite; - } - - .loadingCSSIcon div:nth-child(1) { - background: #e15b64; - animation-delay: -0.4s - } - - .loadingCSSIcon div:nth-child(2) { - background: #f8b26a; - animation-delay: -0.2s - } - - .loadingCSSIcon div:nth-child(3) { - background: #99c959; - animation-delay: 0s; - } - - #loadMask>div>:first-child { - font-size: 40px; - color: #909090; ; - } - - #loadMask>div>:last-child { - width: 140px; - height: 70px; - margin: 0 auto; - } - - #voiceRec { - position: absolute; - right: 0; - top: 0; - width: 47px; - height: 100%; - } - - .message_if_voice { - padding-right: 47px !important; - } - - #voiceRecIcon { - width: 100%; - height: 100%; - text-align: center; - cursor: pointer; - position: relative; - } - - #voiceRecIcon:hover>svg { - color: #808080; - } - - #voiceRecIcon>svg { - width: 38px; - height: 38px; - color: #b0b0b0; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - - #voiceRecIcon>svg .animVoice { - display: none; - } - - .voiceRecing>svg { - color: #99c959 !important; - } - - .voiceRecing .animVoice { - display: inline !important; - transform-origin: 0 64%; - animation-duration: 1.5s; - animation-name: scaleVoice; - animation-timing-function: ease; - animation-iteration-count: infinite; - } - - .voiceLong .animVoice { - display: inline !important; - transform-origin: 0 64%; - animation-duration: 0.3s; - animation-name: longVoice; - animation-timing-function: ease-in-out; - animation-iteration-count: 1; - } - - @keyframes longVoice { - 0% { - transform: scaleY(0); - } - - 100% { - transform: scaleY(1); - } - } - - @keyframes scaleVoice { - 0% { - transform: scaleY(0.28); - } - - 20% { - transform: scaleY(0.60); - } - - 28% { - transform: scaleY(0.28); - } - - 36% { - transform: scaleY(0.45); - } - - 44% { - transform: scaleY(0.28); - } - - 52% { - transform: scaleY(0.45); - } - - 62% { - transform: scaleY(0.80); - } - - 72% { - transform: scaleY(0.80); - } - - 90% { - transform: scaleY(0.28); - } - - 100% { - transform: scaleY(0.28); - } - } - - #voiceRecSetting { - display: none; - position: absolute; - top: -70px; - left: -26px; - z-index: 1; - padding: 4px 4px; - -webkit-user-select: none; - user-select: none; - border-radius: 3px; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); - } - - #voiceRecSetting select { - width: 102px; - outline: none; - height: 28px; - border-radius: 3px; - border-color: rgba(0, 0, 0, .3); - } - - .presetModelCls label { - margin-right: 8px; - } - - .presetModelCls select { - height: 30px; - line-height: 30px; - margin-top: 2px; - font-size: 15px; - } - - .modelSwitch, - .setSwitch { - display: flex; - } - - .modelSwitch { - margin-bottom: 10px; - -webkit-user-select: none; - user-select: none; - } - - .modelSwitch>div, - .setSwitch>div { - border-radius: 3px; - width: calc(100% / 3); - padding: 8px; - line-height: 1; - text-align: center; - cursor: pointer; - } - - .modelSwitch>div:hover, - .setSwitch>div:hover { - background-color: var(--body-hover-bgcolor); - } - - .activeSwitch { - background-color: var(--inputborder-color); - } - - .checkLoad { - height: 32px; - border-radius: 3px; - line-height: 32px; - background: #d0d0d0; - text-align: center; - display: flex; - justify-content: center; - cursor: pointer; - } - - .checkLoad:hover { - background: #c0c0c0; - } - - .voiceChecking { - background-color: #c0c0c0 !important; - } - - .voiceChecking>svg { - display: inline !important; - } - - .checkLoad>svg { - display: none; - margin-right: 8px; - height: 32px; - width: 64px; - } - - #preSetSystem { - height: 30px; - line-height: 30px; - } - - #sysMask { - display: none; - position: fixed; - z-index: 200; - top: 0; - left: 0; - bottom: 0; - right: 0; - cursor: pointer; - justify-content: center; - align-items: center; - background: rgba(0, 0, 0, .4); - -webkit-tap-highlight-color: transparent; - } - - #sysDialog { - position: relative; - background: rgba(var(--primary-rgbbody),1); - cursor: auto; - max-height: 100%; - width: 100%; - display: flex; - flex-direction: column; - border-radius: 4px; - padding: 20px; - } - - .sysTitle { - font-size: 20px; - font-weight: bold; - margin-bottom: 8px; - -webkit-user-select: none; - user-select: none; - } - - .sysSwitch, - .sysSwitch>div * { - pointer-events: none; - } - - .sysSwitch>div { - border-radius: 3px; - height: 32px; - line-height: 32px; - text-align: center; - cursor: pointer; - pointer-events: auto; - font-weight: bold; - display: flex; - align-items: center; - -webkit-user-select: none; - user-select: none; - } - - .sysSwitch>div>svg { - margin-right: 4px; - } - - .sysSwitch>div:hover { - background-color: var(--body-hover-bgcolor); - } - - .sysDetail { - overflow-y: auto; - flex: 1; - margin-top: 4px; - } - - #closeSet { - position: absolute; - right: 0px; - top: 0px; - cursor: pointer; - padding: 10px 14px; - } - - #closeSet:hover { - color: #000; - } - - .setContent { - margin-top: 8px; - } - - .setNotNormalFlow { - position: relative; - } - - .setTitle { - margin-bottom: 6px; - font-weight: bold; - -webkit-user-select: none; - user-select: none; - } - - .setDetail { - margin: 0 10px; - -webkit-user-select: none; - user-select: none; - } - - .autoSelect>label, - .autoSelect>input { - cursor: pointer; - } - - .dataDetail { - display: flex; - } - - .dataDetail svg { - margin-right: 4px; - } - - .dataDetail>div, - .dataDetail>label { - border-radius: 3px; - text-align: center; - padding: 6px 8px; - margin-right: 12px; - font-size: 15px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: space-between; - } - - .dataDetail>div:hover, - .dataDetail>label:hover { - background-color: var(--body-hover-bgcolor); - } - - .keyOptionDetail { - display: flex; - margin-top: 10px; - } - - #resetHotKey { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - font-size: 15px; - border-radius: 3px; - padding: 6px 8px; - margin-left: 90px; - } - - #resetHotKey>svg { - margin-right: 4px; - } - - #resetHotKey:hover { - background-color:var(--body-hover-bgcolor); - } - - .hotKeyDetail>div { - position: relative; - height: 30px; - margin-bottom: 2px; - } - - .hotKeyDetail label { - line-height: 30px; - } - - .hotKeyDetail select { - position: absolute; - left: 110px; - outline: none; - border-radius: 3px; - width: 120px; - border-color: rgba(0, 0, 0, .3); - height: 30px; - font-size: 15px; - } - - .avatarDetail { - display: flex; - margin-top: 2px; - } - - .avatarDetail img { - border-radius: 2px; - width: 32px; - height: 32px; - flex-shrink: 0; - margin-right: 10px; - margin-top: 2px; - } - - .inputDetail input { - outline: none; - border-radius: 3px; - padding-left: 8px; - font-size: 15px; - width: 100%; - height: 34px; - border: 1px solid rgba(0, 0, 0, .3); - } - - .themeDetail { - display: flex; - width: 180px; - justify-content: space-between; - pointer-events: none; - } - - .themeDetail svg { - display: block; - } - - .themeDetail>div { - pointer-events: auto; - border-radius: 20px; - text-align: center; - padding: 8px 8px; - font-size: 14px; - cursor: pointer; - } - - .themeDetail>div * { - pointer-events: none; - } - - .themeDetail>div:hover { - background-color: var(--body-hover-bgcolor); - } - - .darkTheme>div:first-child { - background-color: var(--inputborder-color); - } - - .lightTheme>div:nth-child(2) { - background-color: var(--inputborder-color); - } - - .autoTheme>div:nth-child(3) { - background-color: var(--inputborder-color); - } - - .langDetail { - width: 110px; - } - - .enLang>div:first-child { - background-color: var(--inputborder-color); - } - - .zhLang>div:nth-child(2) { - background-color: var(--inputborder-color); - } - - #customAutoSet input { - width: 100px; - height: 30px; - line-height: 30px; - font-size: 15px; - outline: none; - border: 1px solid rgba(0, 0, 0, .3); - text-align: center; - border-radius: 3px; - background: #fff; - color: #24292f; - } - - #customAutoSet label { - margin-right: 8px; - } - - .progressBar { - position: relative; - width: 100%; - height: 12px; - border-radius: 6px; - background: #e0e0e0; - overflow: hidden; - } - - .nowProgress { - position: absolute; - left: 0; - top: 0; - height: 12px; - min-width: 1px; - border-radius: 6px; - background: #99c959; - } - - .progressDetail { - display: flex; - justify-content: space-between; - font-size: 15px; - } - - .cursorCls { - background-color: var(--inputborder-color); - width: 4px; - animation: 1s cursor-blinker infinite step-start; - } - - @keyframes cursor-blinker { - 0% { - opacity: 0; - } - - 50% { - opacity: 1; - } - - 100% { - opacity: 0; - } - } - - .apiSelsContainer { - position: relative; - z-index: 1; - } - - #apiSelect { - position: absolute; - top: 64px; - padding: 4px 0; - width: calc(100% - 20px); - left: 10px; - box-shadow: 0 0 6px rgba(0, 0, 0, 0.15); - max-height: 180px; - overflow-y: auto; - -webkit-user-select: none; - user-select: none; - } - - #apiSelect>div { - pointer-events: auto; - cursor: pointer; - font-size: 15px; - padding: 6px 0 6px 8px; - height: 36px; - display: flex; - justify-content: space-between; - align-items: center; - } - - #apiSelect>div:hover { - background-color:var(--body-hover-bgcolor); - } - - #apiSelect>div>span { - height: 100%; - line-height: 23px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - #apiSelect>div * { - pointer-events: none; - } - - .delApiOption:hover { - background-color: var(--body-hover-bgcolor); - } - - .delApiOption { - width: 36px; - height: 36px; - flex-shrink: 0; - pointer-events: auto !important; - } - - .delApiOption>svg { - margin: 6px; - display: block; - } - - @keyframes textInOut { - from { - opacity: 0; - } - - to { - opacity: 1; - } - } - - .response[data-loading="true"]>.responseBody>*:not(.cursorCls) { - animation: textInOut 0.48s ease-in-out forwards; - } - - .cotBtn { - width: 120px; - height: 32px; - font-size: 14px; - user-select: none; - background-color: var(--inputborder-color); - position: relative; - padding-left: 14px; - line-height: 32px; - border-radius: 8px; - margin-bottom: 12px; - cursor: pointer; - } - - .cotBtn:hover { - background-color: var(--body-hover-bgcolor); - } - - .cotBtn.endCot { - width: 142px; - } - - .cotBtn::after { - content: ''; - display: inline-block; - position: absolute; - right: 15px; - top: 14px; - width: 6px; - height: 6px; - transform: rotate(-45deg); - } - - .cotBtn.down::after { - top: 11px; - transform: rotate(135deg); - } - - .cotContent { - background-color: var(--inputborder-color); - padding-left: 12px; - font-size: 14px; - } - - .cotContent>:last-child { - margin-bottom: 12px !important; - } - - .hide { - display: none; - } - - - - - - - diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png b/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png deleted file mode 100644 index f29b31f9..00000000 Binary files a/luci-theme-footstrap/htdocs/luci-static/footstrap/logo_48.png and /dev/null differ diff --git a/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json b/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json deleted file mode 100644 index 1694b462..00000000 --- a/luci-theme-footstrap/htdocs/luci-static/footstrap/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "/cgi-bin/luci/", - "name": "LuCI", - "short_name": "LuCI", - "start_url": "/cgi-bin/luci/", - "scope": "/cgi-bin/luci/", - "display": "standalone", - "background_color": "#f6f8fa", - "theme_color": "#f6f8fa", - "icons": [ - { - "src": "/luci-static/footstrap/app-icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any maskable" - } - ] -} diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js deleted file mode 100644 index 65352fa3..00000000 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-appearance.js +++ /dev/null @@ -1,851 +0,0 @@ -'use strict'; -'require baseclass'; -'require ui'; -'require dom'; -'require fs-prefs as prefs'; -'require fs-axes as axes'; -'require fs-assets as assets'; -'require fs-version as ver'; - -/* The Appearance controls: the DOM that presents the axes. It owns no preference — fs-prefs.js - * holds the axes and fs-version.js the version string; this file is the form they are shown in. - * - * Built once, for the dispatched page: `view/footstrap/appearance.js` — a menu entry, an ordinary - * route — calls `renderStandalone()` below, the whole surface this file exposes. Before the page - * existed the same form was stapled onto a tab on System -> System instead, watching for the stock - * form's own tab strip with a MutationObserver, a 5 s deadline, a `body[data-page]` tracker, a - * stale-group WeakSet disqualifying the outgoing page's own DOM from a false match, and a - * sessionStorage flag so a reset's reload landed back on the tab rather than whichever one LuCI - * remembered. A Save & Apply on a real router once lost that tab outright, never reproduced on - * either stand — a mount stapled onto another app's own render is a timing dependency on that - * render, by construction, and the most likely account of a user report that could never be - * reproduced either way. All of it is gone now that the page owns a route of its own. Twenty-one - * axes, nine of them with a colour field, a swatch and a contrast readout, do not fit a floating - * popover that has to trap Tab and stay inside a 320px column, and keeping both containers would - * render every axis twice. - * - * The version line makes no request and must not grow one: which version is INSTALLED is what this - * page answers, and which is available is the package manager's question. */ - -/* ---- colour: reading what the page is actually painted ---- - * - * This lived in fs-widgets.js, which the menu and the search palette also require — so the whole - * colour engine was downloaded on every admin page to be used on this one. It is 3 KB of probe, - * canvas and WCAG arithmetic that nothing outside this form has ever called: `colorControl` was - * fs-widgets' only colour export and this file its only consumer. */ - -/* ---- colour: reading what the page is actually painted ---- - * - * Two questions no stored value answers: what colour a role is right now (the palette's own while - * the axis is off — there is deliberately no copy of the palette in JS), and what contrast the - * user's colour lands at. Both are about the computed cascade, so both are asked of the browser. - * - * `getComputedStyle(root).getPropertyValue('--fs-accent')` answers neither: a custom property - * computes to the token stream after var() substitution, so `oklch(from … l c H)` comes back - * unevaluated. Setting the expression as a real `color` and reading it back makes the browser - * resolve it — relative colour, color-mix() and the tint's calc() are what the theme is made of. - * One hidden probe is reused; an element per query would thrash layout on every slider drag. */ -let _probe = null; -function probeColor(expr) { - if (!_probe) { - /* Off-screen rather than display:none, so the reading does not depend on a display:none - * element computing `color` in every engine. It has no text and no size, so it paints - * nothing. - * - * Every declaration is !important (issue #19): this is an unmarked element in a document - * shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks - * a layer and a plain inline style alike. A probe that loses its own colour reports the - * app's, which then becomes the admin's saved axis on the next confirm. */ - _probe = E('span', { 'aria-hidden': 'true' }); - _probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;' - + 'width:0!important;height:0!important;overflow:hidden!important;' - + 'pointer-events:none!important;'; - document.body.appendChild(_probe); - } - /* cleared first: an expression the engine rejects leaves the previous colour standing, which - * would report a stale answer as a fresh one */ - _probe.style.setProperty('color', ''); - _probe.style.setProperty('color', expr, 'important'); - return getComputedStyle(_probe).color; -} - -/* A computed colour -> [r,g,b] 0..255, or null. Rasterised, not parsed: a computed `color` keeps - * the space it was authored in, so `oklch(0.54 0.19 300)` would parse as three numbers in the - * wrong units and produce a colour nobody chose — measured: #010078, graded "Too faint to read", - * in the hex field, the swatch and the contrast readout alike. Painting one pixel makes the engine - * convert instead (tools/export-tier.mjs uses the same method). The string parse remains only as - * the fallback for an engine with no 2D context, where only the legacy `rgb()`/`color(srgb …)` - * forms can appear. */ -let _cx = null; -function rasterCtx() { - if (_cx !== null) return _cx; - try { - const cv = document.createElement('canvas'); - cv.width = cv.height = 1; - _cx = cv.getContext('2d', { willReadFrequently: true }) || false; - } catch (e) { _cx = false; } - return _cx; -} -function parseColor(s) { - const str = String(s || ''); - const cx = rasterCtx(); - if (cx) { - /* fillStyle keeps the last value it could parse, so a colour this engine rejects would - * report the previous one as a fresh reading — the trap probeColor() clears for */ - cx.fillStyle = '#000'; - cx.fillStyle = str; - cx.clearRect(0, 0, 1, 1); - cx.fillRect(0, 0, 1, 1); - const d = cx.getImageData(0, 0, 1, 1).data; - if (d[3] === 255) return [ d[0], d[1], d[2] ]; - /* translucent: composite over nothing is meaningless for a readout, so fall through */ - } - const nums = str.match(/[\d.]+/g); - if (!nums || nums.length < 3) return null; - const unit = (/^color\(/i).test(str) ? 255 : 1; - return nums.slice(0, 3).map((n) => Math.max(0, Math.min(255, parseFloat(n) * unit))); -} - -/* WCAG 2.x relative luminance and contrast ratio, on sRGB. Used only to report: the theme states - * what a colour costs and leaves the choice with the user, never correcting it (03-palettes.css - * derives the ink over a fill, which is a different question). */ -function luminance(rgb) { - const c = rgb.map((v) => { - const x = v / 255; - return (x <= .03928) ? (x / 12.92) : Math.pow((x + .055) / 1.055, 2.4); - }); - return (.2126 * c[0]) + (.7152 * c[1]) + (.0722 * c[2]); -} -function contrastRatio(fgExpr, bgExpr) { - const fg = parseColor(probeColor(fgExpr)), bg = parseColor(probeColor(bgExpr)); - if (!fg || !bg) return null; - const a = luminance(fg), b = luminance(bg); - return (Math.max(a, b) + .05) / (Math.min(a, b) + .05); -} - -/* #rrggbb, because accepts nothing else. An unparseable colour becomes black - * rather than throwing: the text field beside the swatch is the authoritative one. */ -function toHex(s) { - const rgb = parseColor(s) || [ 0, 0, 0 ]; - return '#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join(''); -} - -/* One colour axis: a native swatch, a hex field and a button back to the palette's own colour. - * Reports through onPick as a hex string, or 0 for "back to the palette", either of which the - * caller hands straight to fs-prefs.js's colorAxis. - * - * There is no hue slider and one is not coming back: rotating a hue keeps the palette's chroma, - * so no angle of it reaches a grey. The axis still accepts a stored hue (1–360) and the stylesheet - * still rotates the palette by one, so a saved value goes on working. - * - * `opts.probe` is the live token the effective colour is read back from, so the field shows the - * palette's colour while the axis is off without a copy of the palette in JS. `opts.contrast` is - * the pair whose ratio is reported under the row. */ -function colorControl(current, onPick, label, opts) { - const o = opts || {}; - - /* type=color leaves the picker to the browser: accessible without reimplementing a colour - * wheel, and native on a phone. The text field beside it takes a pasted hex and is the - * fallback where the browser draws no picker. */ - const swatch = E('input', { 'type': 'color', 'class': 'fs-color-swatch', 'aria-label': label || '' }); - const field = E('input', { - 'type': 'text', 'class': 'fs-color-hex', 'spellcheck': 'false', 'autocomplete': 'off', - 'inputmode': 'text', 'maxlength': '7', 'aria-label': label || '' - }); - const clear = E('button', { 'class': 'btn fs-color-clear', 'type': 'button' }, [ _('Palette', 'footstrap') ]); - const ratio = o.contrast ? E('div', { 'class': 'cbi-value-description fs-color-contrast' }) : null; - - /* what the axis holds right now: the page can change it behind this control (a preset, Reset - * to default), so a private copy would go stale. `current` is only the build-time value. */ - const currentOf = o.read || (() => current); - - /* Repaint everything that mirrors the axis. Called after every edit, and through the returned - * refresh() after a preset, palette switch or dark-mode flip — each changes what the palette's - * own colour is while this axis stays off. */ - function reflect(v) { - const live = probeColor(o.probe); - const hex = (typeof v === 'string') ? v : toHex(live); - swatch.value = hex; - /* do not fight the user mid-edit: `#0` is a legal prefix, and overwriting the field on - * every keystroke made the input impossible to type into */ - if (document.activeElement !== field) field.value = hex; - /* the button back to the palette doubles as the axis state readout: enabled means the axis - * holds a colour of its own, disabled means the field shows the palette's */ - clear.disabled = !v; - if (!ratio) return; - const r = contrastRatio(o.contrast.fg, o.contrast.bg); - if (r === null) { ratio.textContent = ''; ratio.removeAttribute('title'); return; } - /* The readout states what the ratio means; the number itself stays in the title. - * Thresholds are WCAG AA: 4.5:1 for body text, 3:1 for large text and for a UI shape, so a - * hairline is graded on the second (`kind: 'shape'`) and warns rather than fails — a faint - * border is a legitimate choice. - * - * Class names are written out whole: tools/fs-orphans.mjs sweeps dead CSS by matching - * fs-* tokens in the source, and a concatenated name is invisible to it. */ - const where = o.contrast.label; - const grade = (o.contrast.kind === 'shape') - ? ((r >= 3) - ? { cls: 'fs-contrast-aa', text: _('Clearly visible %s', 'footstrap').format(where) } - : { cls: 'fs-contrast-aa-large', text: _('Barely visible %s', 'footstrap').format(where) }) - : (r >= 4.5) - ? { cls: 'fs-contrast-aa', text: _('Easy to read %s', 'footstrap').format(where) } - : (r >= 3) - ? { cls: 'fs-contrast-aa-large', text: _('Hard to read %s — large text only', 'footstrap').format(where) } - : { cls: 'fs-contrast-low', text: _('Too faint to read %s', 'footstrap').format(where) }; - ratio.className = 'fs-color-contrast ' + grade.cls; - ratio.textContent = grade.text; - ratio.title = _('Contrast %s:1 (WCAG AA wants %s:1 here)', 'footstrap') - .format(r.toFixed(1), (o.contrast.kind === 'shape') ? '3' : '4.5'); - } - - const pick = (v) => { onPick(v); reflect(v); }; - - swatch.addEventListener('input', () => pick(swatch.value.toLowerCase())); - /* commit on blur and Enter, not per keystroke: a half-typed `#0096` would repaint the page - * under the cursor. An unparseable value snaps back to what the axis holds, so the field - * cannot claim a colour the page is not painted in. */ - const commit = () => { - const v = field.value.trim().toLowerCase(); - if ((/^#[0-9a-f]{6}$/).test(v)) pick(v); - else reflect(currentOf()); - }; - field.addEventListener('blur', commit); - field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); commit(); } }); - clear.addEventListener('click', () => pick(0)); - - const wrap = E('div', { 'class': 'fs-colorctl' + (o.cls ? ' ' + o.cls : '') }, [ - E('div', { 'class': 'fs-color-row' }, [ swatch, field, clear ]) - ].concat(ratio ? [ ratio ] : [])); - /* the caller decides when this runs: probeColor() needs the document, and this control is not - * in it yet */ - wrap.fsRefresh = () => reflect(currentOf()); - return wrap; -} - -/* Build the whole form. Returns the root element, for renderStandalone() below to hand to the - * page's own render(). - * - * Everything applies immediately and there is nothing to save: every axis is this browser's, in - * localStorage, and the page repaints under the control as it moves. Only "Save to router" writes - * anything, pushing the current look to the ROUTER for other browsers. That distinction is the - * model (docs/design-system.md), and why this page has no Save/Reset footer of LuCI's own — - * view/footstrap/appearance.js nulls handleSave/handleSaveApply/handleReset, which is what actually - * drops it: view.js's own default footer (Save & Apply | Apply unchecked | Save | Reset, wired to - * apply_rollback) renders and runs otherwise, POSTing on a form with nothing staged (measured, - * live, before that null). */ -function build() { - /* every saved axis re-checks the Save button after applying, so it greys the moment this - * browser matches the saved default and un-greys when it diverges. Wrapped around the appliers - * because the controls call them directly and have no other seam back here. */ - const bump = (fn) => (v) => { fn(v); refreshSave(); }; - - /* Every colour control mirrors something it does not own: the palette's colour while its own - * axis is off, and the contrast that colour lands at. A palette switch or dark-mode flip moves - * all of it under controls nobody touched, so they refresh together. */ - const colourCtls = []; - const refreshColours = () => colourCtls.forEach((c) => c.fsRefresh()); - /* wrap an applier so the colour readouts follow it: mode and palette change what every axis is - * measured against */ - const repaint = (fn) => (v) => { fn(v); refreshColours(); }; - - /* One captioned row in LuCI's own shape: `.cbi-value` > `label.cbi-value-title` + - * `.cbi-value-field`. Nothing here styles those class names — base/30-forms.css and - * theme/60-inputs.css already lay them out — so this page inherits every future fix to the form - * layout instead of keeping a private copy. It also puts the row on a shared surface (zone 2, - * where an app may win on specificity), which is right for a page inside #view. - * - * `make` is handed the same label string the caption renders, because every control needs it a - * second time as its aria-label; stating it twice is how the visible caption and what a screen - * reader announces drift apart. `extra` carries rows with more than a control, `opts.cls` marks - * the rows CSS has to single out. */ - const group = (label, make, opts) => { - const o = opts || {}; - return E('div', { 'class': 'cbi-value' + (o.cls ? ' ' + o.cls : '') }, [ - E('label', { 'class': 'cbi-value-title' }, [ label ]), - E('div', { 'class': 'cbi-value-field' }, [ make(label) ].concat(o.extra || [])) - ]); - }; - - /* the three literals every colour row repeats; a string literal survives minification intact */ - const CARD_BG = 'var(--fs-panel)', INK = 'var(--fs-text)', ON_CARD = _('on a card', 'footstrap'); - - /* ---- the controls are LuCI's own ---- - * - * Every enum axis is a `ui.Select` and every number a `ui.RangeSlider`: the widgets the other - * tabs are built from, already dressed by this stylesheet (`select` in base/30-forms.css, - * `.cbi-range-slider` in theme/60-inputs.css). Both exist on every release this theme supports - * — checked against openwrt-24.10, since `ui.RangeSlider` is the one that did not exist further - * back. - * - * Listen for `widget-change` from UIElement, not for an event on the inner element: that is the - * seam the widget publishes, and reaching past it ties this to how the widget happens to be - * built. RangeSlider also emits `widget-update` while the handle moves, which is what resizes - * the page under the drag; the appliers are idempotent, so wiring both costs nothing. */ - const selectCtl = (current, choices, apply, label) => { - const w = new ui.Select(String(current), choices, { widget: 'select', sort: Object.keys(choices) }); - const node = w.render(); - node.setAttribute('aria-label', label); - node.addEventListener('widget-change', () => apply(w.getValue())); - return node; - }; - - const sliderCtl = (current, min, max, apply, label, opts) => { - const o = opts || {}; - const w = new ui.RangeSlider(String(current), { - min: min, max: max, step: o.step || 1 - }); - const node = w.render(); - node.setAttribute('aria-label', label); - const push = () => apply(parseInt(w.getValue(), 10)); - /* `live: false` for an axis whose value moves THIS CONTROL. Content width widens the column - * the Appearance page is drawn in, so applying mid-drag slides the handle out from under - * the pointer, the pointer catches up, and one small drag runs the value to the maximum — - * reported from a router, and not a thing any other slider here can do: rounding and the - * tint sliders repaint around a control that stays where it is. On release the page still - * follows, and a keyboard user is unaffected either way: an arrow key on a range input - * fires `change` as well as `input`, so the value applies at once. */ - if (o.live !== false) node.addEventListener('widget-update', push); - node.addEventListener('widget-change', push); - return node; - }; - - /* one colour axis: `probe` is the live token the control reads the effective colour back from, - * `contrast` the pair it reports */ - const colourGroup = (label, axis, probe, contrast, opts) => group(label, (lbl) => { - const ctl = colorControl(axis.current(), bump(axis.apply), lbl, { - probe: probe, - read: axis.current, - contrast: contrast, - cls: (opts && opts.cls) || '' - }); - colourCtls.push(ctl); - return ctl; - }, opts); - - /* Every label here carries the 'footstrap' context (`_(str, ctx)`, key `ctx\1str`). LuCI serves - * one merged catalogue — load_catalog() loads every *..lmo and a lookup returns the first - * archive holding the hash — so a bare msgid is a global name any luci-app may take, and - * readdir order picks the winner: the layout toggle rendered "Максимум" on a Russian router - * because another catalogue translates "Top" as "maximum" (issue #6). Contexting cannot be - * selective. The chrome, the login/notice sentences and the System/Memory/Storage headings are - * deliberately bare — inheriting luci-base's translation covers the ~40 languages this theme - * has no catalogue for. */ - - /* ---- section 1: the shell ---- */ - const shell = [ - group(_('Layout', 'footstrap'), (label) => selectCtl(prefs.currentLayout(), { - sidebar: _('Sidebar', 'footstrap'), - top: _('Top', 'footstrap') - }, bump(prefs.applyLayout), label)), - - group(_('Theme', 'footstrap'), (label) => selectCtl(prefs.currentMode(), { - auto: _('Auto', 'footstrap'), - light: _('Light', 'footstrap'), - dark: _('Dark', 'footstrap') - }, bump(repaint(prefs.applyMode)), label)), - - group(_('Palette', 'footstrap'), (label) => selectCtl(axes.currentPalette(), { - footstrap: 'Footstrap', - hicontrast: 'Hi-Contrast', - /* names the OTHER package, luci-theme-bootstrap, whose colours this palette is — - * so it is a proper noun and stays untranslated, like the two above it */ - bootstrap: 'Bootstrap', - /* names the OTHER package again, luci-theme-openwrt-2020, whose colourway this is */ - '2020': 'OpenWrt 2020', - /* names the OpenWrt forum (forum.openwrt.org), whose Discourse colourway this is — - * a proper noun like the three above it, not the English common noun "forum" */ - forum: 'Forum' - }, bump(repaint(axes.applyPalette)), label)), - - group(_('Density', 'footstrap'), (label) => selectCtl(prefs.currentDensity(), { - compact: _('Compact', 'footstrap'), - normal: _('Normal', 'footstrap'), - large: _('Large', 'footstrap') - }, bump(prefs.applyDensity), label)), - - /* issue #44: the content column's own cap, separate from Density (which moves type and - * air, not the column's ceiling). The slider STARTS at the 1280px the theme has always - * shipped and only ever widens — there is no reason to offer a column narrower than the - * one every page was designed against, and keeping 1280 as the left end also keeps - * --fs-content-min (500px, what the sidebar-to-bar fold is measured against) out of reach - * by construction rather than by a rule someone has to remember. */ - group(_('Content width', 'footstrap'), - (label) => sliderCtl(axes.currentContentWidth(), 1280, 3840, - bump(axes.applyContentWidth), label, { step: 40, live: false })), - - group(_('Rounding', 'footstrap'), - (label) => sliderCtl(axes.currentRadius(), 0, 20, bump(axes.applyRadius), label)), - - /* The top layout has no accordion, so this switch is meaningless there: always built, - * hidden by CSS (:root[data-layout="top"] .fs-ap-submenus). Do not wrap it in an - * `if (currentLayout() !== 'top')` — the page is built once, so the branch would freeze - * the control to the layout the page loaded in while CSS morphs the chrome live. */ - group(_('Submenus', 'footstrap'), (label) => selectCtl( - prefs.currentAutoCollapse() ? 'on' : 'off', { - off: _('Keep open', 'footstrap'), - on: _('Auto-collapse', 'footstrap') - }, bump(prefs.applyAutoCollapse), label), - { cls: 'fs-ap-submenus' }) - ]; - - /* ---- section 2: colours ---- */ - const colours = [ - /* the caption says what the axis is for: "Tint" alone reads as decoration, and nobody - * would look for the router-identity cue under it */ - colourGroup(_('Tint (router identification)', 'footstrap'), { - current: axes.currentTint, apply: axes.applyTint - }, 'var(--fs-bg)', { - /* the canvas is the one axis with no derived ink: its text is --fs-text, a palette - * token this axis must not move, so the ratio is reported instead of corrected */ - fg: 'var(--fs-text)', bg: 'var(--fs-bg)', label: _('on the canvas', 'footstrap') - }, { cls: 'fs-ap-tint' }), - - /* The strength half of the Tint, meaningful only in hue mode: a hex canvas is the colour - * asked for, with no chroma of ours to scale. CSS hides it in the other two states. - * - * Not called "Density": that is the select above, and this string is both the caption and - * the aria-label, so a screen reader would announce two rows under one name. */ - group(_('Tint strength', 'footstrap'), - (label) => sliderCtl(axes.currentTintStrength(), 0, 200, bump(repaint(axes.applyTintStrength)), label, { - step: 5 - }), { cls: 'fs-ap-tint fs-ap-tintstr' }), - - /* recolours the accented controls (buttons/toggles/sliders/focus rings), not the canvas. - * Measured as text on a card, the use that fails first: as a fill it carries derived ink, - * as a link or status label it carries only itself. It is also what answers #20 ("sometimes - * you want grey or black"), taking any #rrggbb — the colour-chip presets that once sat here - * are not coming back. */ - /* Four status roles, one shape: the role's own colour read against a card. Written out - * eight times between here and the surfaces below, they cost their repeated literals in - * full — a string is not mangled — so the rows are data and the row is stated once. */ - ...[ - [ _('Accent', 'footstrap'), axes.currentAccent, axes.applyAccent, 'var(--fs-accent)' ], - [ _('Good', 'footstrap'), axes.currentGood, axes.applyGood, 'var(--fs-good)' ], - [ _('Warning', 'footstrap'), axes.currentWarn, axes.applyWarn, 'var(--fs-warn)' ], - [ _('Danger', 'footstrap'), axes.currentDanger, axes.applyDanger, 'var(--fs-danger)' ] - ].map(([ label, current, apply, ink ]) => - colourGroup(label, { current, apply }, ink, { fg: ink, bg: CARD_BG, label: ON_CARD })) - ]; - - /* ---- the surfaces: the sheet the UI is drawn on ---- - * Cards, inset controls, the chrome bar and the hairlines between them. Body text is read on - * each, so each reports --fs-text against itself. No ink is derived here: --fs-text is the - * palette's, and moving it would recolour the very thing being measured against. - * - * The hairline takes the 3:1 UI-component threshold instead — a border is a shape, not a label - * — and below that it is decoration, which a hairline is entitled to be, so the readout states - * the number and leaves the call to the admin. */ - const surfaces = [ - /* Same rows, one column wider: a surface reports the ink read ON it, which is --fs-text - * for the three that carry body text and the hairline itself for the border. */ - ...[ - [ _('Cards', 'footstrap'), axes.currentCard, axes.applyCard, CARD_BG, INK, CARD_BG, ON_CARD ], - [ _('Controls', 'footstrap'), axes.currentControl, axes.applyControl, 'var(--fs-panel2)', INK, 'var(--fs-panel2)', _('on a control', 'footstrap') ], - [ _('Sidebar and bar', 'footstrap'), axes.currentBar, axes.applyBar, 'var(--fs-bar-bg)', INK, 'var(--fs-bar-bg)', _('in the sidebar', 'footstrap') ], - [ _('Borders', 'footstrap'), axes.currentLine, axes.applyLine, 'var(--fs-border)', 'var(--fs-border)', CARD_BG, ON_CARD, 'shape' ] - ].map(([ label, current, apply, probe, fg, bg, where, kind ]) => - colourGroup(label, { current, apply }, probe, { fg, bg, label: where, kind })) - ]; - - /* ---- section 3: the wallpaper and the rows each value brings ---- - * - * Wallpaper is three-valued: Off, Pattern (an uploaded SVG, tiled and recoloured) and File (an - * uploaded photo). The rows a value brings are SIBLINGS of the Wallpaper row, not children of - * its field: nesting a `.cbi-value` inside a `.cbi-value-field` puts a second caption column - * inside the first, and those controls started 216px right of every other one on the page, - * measured on the router. Flat rows hidden as a group is what the stock pages do with a - * dependent field. - * - * The select is the per-browser switch deciding whether to paint an image, so it is what keeps - * the Save button honest; Choose/Remove only swap the picture and never touch the axis. The - * native file inputs stay hidden — the styled buttons trigger them. */ - const wallpaper = (() => { - const err = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); - const preview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); - /* display:none, not `hidden`: a bare `hidden=""` still renders the native - * "Choose File / No file chosen" control */ - const fileInput = E('input', { 'type': 'file', 'accept': 'image/*', 'style': 'display:none' }); - const chooseLabel = _('Choose image', 'footstrap'); - const chooseBtn = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ chooseLabel ]); - const removeBtn = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]); - - const patErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); - const patPreview = E('img', { 'class': 'fs-ap-bgprev', 'alt': '', 'hidden': '' }); - const patInput = E('input', { 'type': 'file', 'accept': 'image/svg+xml,.svg', 'style': 'display:none' }); - const patChooseLabel = _('Choose SVG', 'footstrap'); - const patChoose = E('button', { 'class': 'btn cbi-button', 'type': 'button' }, [ patChooseLabel ]); - const patRemove = E('button', { 'class': 'btn cbi-button-remove', 'type': 'button', 'hidden': '' }, [ _('Remove', 'footstrap') ]); - - /* Dim: the scrim opacity over the photo. An ordinary per-browser axis — it is in AXIS_KEYS - * and snapshotAxes(), so it moves this browser toward or away from the router default and - * must be bump()-ed like every other saved axis, or the Save button misreports its own - * status. Separate from the Tint strength above. */ - const dimLabel = _('Dim', 'footstrap'); - const scaleLabel = _('Scale', 'footstrap'); - const strengthLabel = _('Strength', 'footstrap'); - const inkLabel = _('Colours', 'footstrap'); - - /* The rows the pattern brings. Scale and Strength are live: the appliers write a custom - * property, so the tile resizes and fades under the drag. Colours decides whether the - * file's own palette is kept or replaced by the theme's — a mask uses the alpha only, - * which is right for line art and wrong for artwork that carries its own colours. */ - const patRows = [ - group(_('Pattern', 'footstrap'), - () => E('div', { 'class': 'fs-ap-bgrow' }, [ patChoose, patRemove ]), - { extra: [ patInput, patPreview, patErr ] }), - group(scaleLabel, (lbl) => sliderCtl(axes.currentPatternSize(), 40, 1600, - bump(axes.applyPatternSize), lbl, { step: 20 })), - group(strengthLabel, (lbl) => sliderCtl(axes.currentPatternStrength(), 0, 100, - bump(axes.applyPatternStrength), lbl, { step: 5 })), - group(inkLabel, (lbl) => selectCtl(axes.currentPatternInk(), { - theme: _('Theme', 'footstrap'), - original: _('As in file', 'footstrap') - }, bump(axes.applyPatternInk), lbl)) - ]; - /* …and the rows the FILE photo brings. */ - const fileRows = [ - group(_('File', 'footstrap'), - () => E('div', { 'class': 'fs-ap-bgrow' }, [ chooseBtn, removeBtn ]), - { extra: [ fileInput, preview, err ] }), - group(dimLabel, (lbl) => sliderCtl(axes.currentPhotoDim(), 0, 100, - bump(axes.applyPhotoDim), lbl, { step: 5 })) - ]; - - function reflect(tok) { - if (tok) { preview.src = axes.loginBgUrl(tok); preview.hidden = false; removeBtn.hidden = false; } - else { preview.removeAttribute('src'); preview.hidden = true; removeBtn.hidden = true; } - } - function reflectPattern(tok) { - if (tok) { patPreview.src = axes.patternUrl(tok); patPreview.hidden = false; patRemove.hidden = false; } - else { patPreview.removeAttribute('src'); patPreview.hidden = true; patRemove.hidden = true; } - } - /* `hidden` on the row, which 80-appearance.css restates at a specificity beating - * `.cbi-value`'s own display (the UA's bare `[hidden]` rule loses to it). Hidden, not - * removed: each row holds a live control, so rebuilding on every switch would freeze it to - * the state it was constructed in. */ - function togglePanel(v) { - patRows.forEach((r) => { r.hidden = (v !== 'pattern'); }); - fileRows.forEach((r) => { r.hidden = (v !== 'file'); }); - } - reflect(axes.currentLoginBg()); - reflectPattern(axes.currentPattern()); - togglePanel(axes.currentWallpaper()); - - const setWallpaper = (v) => { axes.applyWallpaper(v); refreshSave(); togglePanel(v); refreshColours(); }; - - /* Both uploads present the same three controls and the same four states — pick, upload, - * report, remove — so the wiring is stated once. What differs is `after`: the pattern also - * has to move the Wallpaper dropdown, because the upload switched this browser onto the - * tile and the page would otherwise paint it while the control still read Off. - * - * The file input is cleared on every change so re-picking the SAME file fires `change` - * again, and the button carries its own busy state: the label is restored in `finally`, or - * a failed upload leaves "Uploading…" standing for the life of the form. */ - const wireUploader = (u) => { - const fail = (e) => { u.err.textContent = String((e && e.message) || e); u.err.hidden = false; }; - u.choose.addEventListener('click', () => { u.err.hidden = true; u.input.click(); }); - u.input.addEventListener('change', () => { - const f = u.input.files && u.input.files[0]; - u.input.value = ''; - if (!f) return; - u.err.hidden = true; u.choose.disabled = true; - u.choose.textContent = _('Uploading…', 'footstrap'); - u.upload(f) - .then((tok) => { u.reflect(tok); if (u.after) u.after(tok); }) - .catch(fail) - .finally(() => { u.choose.disabled = false; u.choose.textContent = u.label; }); - }); - u.remove.addEventListener('click', () => { - u.err.hidden = true; u.remove.disabled = true; - u.drop().then(() => u.reflect('')).catch(fail) - .finally(() => { u.remove.disabled = false; }); - }); - }; - - wireUploader({ - choose: patChoose, remove: patRemove, input: patInput, err: patErr, - label: patChooseLabel, reflect: reflectPattern, - upload: assets.uploadPattern, drop: assets.removePattern, - /* `dom.callClassMethod` is how LuCI moves its own widgets from outside; setWallpaper is - * then called directly, because a programmatic setValue emits no `widget-change`. */ - after: () => { dom.callClassMethod(seg, 'setValue', 'pattern'); setWallpaper('pattern'); } - }); - - wireUploader({ - choose: chooseBtn, remove: removeBtn, input: fileInput, err: err, - label: chooseLabel, reflect: reflect, - upload: assets.uploadLoginBg, drop: assets.removeLoginBg - }); - - let seg; - const wallRow = group(_('Wallpaper', 'footstrap'), (label) => { - seg = selectCtl(axes.currentWallpaper(), { - off: _('Off', 'footstrap'), - pattern: _('Pattern', 'footstrap'), - file: _('File', 'footstrap') - }, setWallpaper, label); - return seg; - }); - - return [ wallRow ].concat(patRows, fileRows); - })(); - - /* ---- section 4: the router default and the version ---- - * - * Save the current look as the router-wide default (fs-prefs writes /etc/config/footstrap over - * the scoped uci ACL). It does not change this browser — localStorage keeps overriding — so the - * saved default only shows on a fresh browser. The two Reset buttons below are the escape - * hatches, and they do not land in the same place. */ - /* the button/status label said three times below, inside this one build() call — hoisting the - * RESULT, not the msgid, so update-po.sh still sees the literal `_()` argument elsewhere - * (measured: 22 B x3 -> 27 B, 39 B saved) */ - const SAVE_TO_ROUTER = _('Save to router', 'footstrap'); - /* said twice below, same build() call (measured: 24 B x2 -> 30 B, 18 B saved) */ - const RESET_TO_ROUTER = _('Reset to router', 'footstrap'); - const saveBtn = E('button', { 'class': 'btn cbi-button-action', 'type': 'button' }, [ SAVE_TO_ROUTER ]); - /* Two resets, because two things sit underneath a browser's tweaks (fs-prefs.js): "Reset to - * router" clears them and lets every axis fall back to whatever the router holds, while "Reset - * to built-in" writes the theme's built-ins explicitly — the only way to say "as the theme - * ships" on a router that has a look of its own. Neither touches /etc/config/footstrap. - * - * ONE word per state, and "default" is not one of them: it used to name the router's look in - * "Save as default" and the theme's in "Reset to default", while the router's look also - * answered to "saved" — three names for two states, asked about on the forum (topic 251930, - * post 92). The row now reads as one save and two resets, over `router` and `built-in`. */ - const resetSavedBtn = E('button', { 'class': 'btn', 'type': 'button' }, [ RESET_TO_ROUTER ]); - /* the stock destructive class, so the button discarding every local tweak is the red one - * (theme/55-buttons.css). "Reset to router" stays neutral: it steps back to the shared state - * rather than discarding. */ - const resetBtn = E('button', { 'class': 'btn cbi-button-negative', 'type': 'button' }, [ _('Reset to built-in', 'footstrap') ]); - /* Save's only visible failure surface. The realistic failure is the rpc rejecting — an expired - * session (403), a missing ACL, ubus down. A DELETED config is not caught: rpcd stages the set - * in the session and commit then no-ops without writing the file, returning success (measured - * on the router). The package owns that file and the read side falls back to built-in - * defaults. */ - const saveErr = E('div', { 'class': 'fs-ap-err', 'role': 'alert', 'hidden': '' }); - - /* The Save button is the status: matching disables it, diverging enables it. Called after every - * axis change (via bump). - * - * Unless the browser refuses storage, where the comparison would always be true — nothing was - * written, so every current*() reads the router default back however far the page has been - * dragged from it. Say so instead, and leave the button enabled: pushing this browser's look to - * the router is the one thing that still works. */ - function refreshSave() { - if (prefs.storageBroken()) { - saveBtn.disabled = false; - saveBtn.textContent = SAVE_TO_ROUTER; - saveErr.textContent = _('This browser is not storing preferences (site data is blocked), so a change here lasts until you reload. Saving as default still works and applies to every browser.', 'footstrap'); - saveErr.hidden = false; - return; - } - const saved = axes.matchesSavedDefault(); - saveBtn.disabled = saved; - saveBtn.textContent = saved ? _('Saved to router', 'footstrap') : SAVE_TO_ROUTER; - } - saveBtn.addEventListener('click', () => { - saveBtn.disabled = true; - saveErr.hidden = true; - axes.saveAsDefault() - .then(() => { saveErr.hidden = true; }) - /* on failure refreshSave re-enables the button so the user can retry; the usual cause - * is a stale session, which a reload fixes. The raw rpc error stays in a title - * tooltip. */ - .catch((e) => { - saveErr.textContent = _('Could not save the default. Reload the page and try again.', 'footstrap'); - saveErr.title = String((e && e.message) || e); - saveErr.hidden = false; - }) - .finally(refreshSave); - }); - /* Two-click confirm on both: discarding local tweaks is destructive and a native confirm() is - * banned in this UI. Arming one disarms the other, so a primed button cannot be fired by a - * click meant for its neighbour. Each reload lands back on this same page — an ordinary route, - * unlike the tab this once needed a sessionStorage flag to return to. */ - const armed = new Map(); - function disarm(btn, label) { - armed.delete(btn); - btn.textContent = label; - btn.classList.remove('fs-ap-armed'); - } - function twoClick(btn, label, run) { - btn.addEventListener('click', () => { - if (!armed.has(btn)) { - [ ...armed.keys() ].forEach((other) => disarm(other, armed.get(other))); - armed.set(btn, label); - btn.textContent = _('Confirm reset', 'footstrap'); - btn.classList.add('fs-ap-armed'); - return; - } - disarm(btn, label); - run(); - location.reload(); - }); - } - twoClick(resetSavedBtn, RESET_TO_ROUTER, axes.resetToSaved); - twoClick(resetBtn, _('Reset to built-in', 'footstrap'), axes.resetToBuiltin); - refreshSave(); /* correct label and enabled state before the first paint */ - - const versionLink = E('a', { - 'class': 'fs-ap-version', - 'href': ver.REPO_URL, - 'target': '_blank', - /* `noreferrer` alone: it implies noopener, and the theme's other outward links spell it - * that way */ - 'rel': 'noreferrer' - }, [ ver.label() ]); - - /* One clause per button, in LuCI's own help-sentence idiom and under the buttons themselves, - * because that is where the question is asked: with all three named "save"/"reset" and two of - * them resets, the row does not say which state each lands in (forum topic 251930, post 90 — - * "what is the difference on 'reset to saved' and 'reset to default'?"). Three lines rather - * than a paragraph: three parts want a list, and each is read on its own. - * - * Text nodes separated by
, NOT a child per line: `.cbi-value-field *` in - * theme/60-inputs.css hands mono to every descendant and excludes `.cbi-value-description` - * itself, so a wrapped line becomes a descendant that the exclusion does not reach — measured, - * all three lines came out monospace. Text nodes inherit the sans face from the container, and - * one container also means one `?` glyph with every line aligned under it. */ - const buttonHelp = E('div', { 'class': 'cbi-value-description' }, [ - _('Save to router — store this look on the router. Browsers with a look of their own keep it.', 'footstrap'), - E('br'), - _('Reset to router — drop this browser\'s changes and follow the router.', 'footstrap'), - E('br'), - _('Reset to built-in — go back to the look the theme ships with.', 'footstrap') - ]); - - const defaults = [ - /* the one row whose control is a pair of buttons, each named by its own text, so `make` - * ignores the caption rather than re-using it as an aria-label */ - group(_('Saved look', 'footstrap'), - () => E('div', { 'class': 'fs-ap-actrow' }, [ saveBtn, E('span', { 'class': 'fs-ap-actgap', 'aria-hidden': 'true' }), resetSavedBtn, resetBtn ]), - { extra: [ buttonHelp, saveErr ] }) - ]; - - - /* ---- the catalogue this router has not got ---- - * - * The theme's translations are their own packages since 0.14.4, the way every `luci-app-*` - * ships them, and nothing in a package manager can read `uci luci.main.lang` to fetch the right - * one: apk learns it from `install-if` against `luci-i18n-base-` (owfeed.yml), opkg has no - * conditional form of that at all, and a router upgraded from 0.14.3 through the feed simply - * loses the catalogue that used to ride inside the theme (issue #41). `install.sh` covers its - * own path; this covers the one nobody ran a script on. - * - * ASKED OF THE PAGE, not of the package list: the theme has no ubus call of its own and must not - * grow one for this. `_()` returns its argument unchanged when no catalogue answers, so asking - * for a string the catalogue certainly carries is the whole test. - * - * `Layout` is that string — a caption this very form renders, present in every catalogue under `po/`. A word - * that only LOOKS certain is worse than no check: `Appearance` is in the source but obsolete in - * the catalogues (`#~ msgid`), so testing it reported "not translated" on a router whose - * Russian catalogue was installed and working. - * - * The language comes from the document, not from `L.env`, which carries no language field at - * all — the dispatcher stamps `` and that is what the page knows. - * - * Nothing is shown on an English or `auto` router, where there is no catalogue to miss. */ - const lang = (document.documentElement.getAttribute('lang') || '').trim(); - const untranslated = lang && lang !== 'en' && lang !== 'auto' && - _('Layout', 'footstrap') === 'Layout'; - const missing = untranslated ? E('div', { 'class': 'fs-ap-verrow fs-ap-i18n' }, [ - E('span', {}, [ _('This theme is not translated on this router yet.', 'footstrap') + ' ' ]), - E('code', {}, [ 'luci-i18n-footstrap-' + lang ]) - ]) : ''; - - defaults.push(E('div', { 'class': 'fs-ap-footer' }, [ - E('div', { 'class': 'fs-ap-verrow' }, [ versionLink ]), - missing - ])); - - /* not .cbi-section: inside a tab pane that is a card within a card, and the stock tabs put - * their rows straight into the pane. These are grouping headings within one pane, styled by - * pages/80-appearance.css. */ - const section = (title, rows) => E('div', { 'class': 'fs-ap-section' }, [ - E('div', { 'class': 'fs-ap-head' }, [ E('h4', {}, [ title ]) ]) - ].concat(rows)); - - /* ---- the folded groups ---- - * Nine colour fields and an uploader are the widest rows on the page and most admins never - * touch them, so each group is a disclosure, closed by default. - * - * A disclosure and not a switch: a switch answers "is this feature on", and turning it off - * would either revert nine colours or change nothing at all. Opening or closing a fold applies, - * un-applies and disables nothing. - * - * W3C APG disclosure pattern, as the menu's sections use: a diff --git a/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut b/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut deleted file mode 100644 index e0cd7fba..00000000 --- a/luci-theme-footstrap/ucode/template/themes/footstrap/sysauth.ut +++ /dev/null @@ -1,230 +0,0 @@ -{# - The LOGIN page. - - Copyright 2008 Steven Barth - Copyright 2008-2012 Jo-Philipp Wich - Licensed to the public under the Apache License 2.0. - - It exists because the generic template includes the header WITHOUT `blank_page: true`: with no - theme-local sysauth the dispatcher falls back to it and the whole chrome renders around the login - form with every control on it dead. Everything else here is a departure the notes below justify, - and none of them may drop a field the backend posts. - - Do not re-introduce the `