mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-30 15:07:49 -04:00
cli/notify: add priority options
This commit is contained in:
1 parent
ca93837149
commit
5ee570557f
4 files changed
+363
-97
No files matched your search
+238
-27
@@ -3,30 +3,50 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/notify"
|
||||
"github.com/AvengeMedia/dankgo/ipc"
|
||||
"github.com/godbus/dbus/v5"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var (
|
||||
notifyAppName string
|
||||
notifyIcon string
|
||||
notifyFile string
|
||||
notifyTimeout int
|
||||
notifyAppName string
|
||||
notifyAppIcon string
|
||||
notifyIcon string
|
||||
notifyFile string
|
||||
notifyTimeout int
|
||||
notifyUrgency string
|
||||
notifyCategory string
|
||||
notifyTransient bool
|
||||
notifyHints []string
|
||||
notifyActions []string
|
||||
notifyPrintID bool
|
||||
notifyReplaceID uint32
|
||||
notifyWait bool
|
||||
notifyIDFd int
|
||||
notifyActionFd int
|
||||
)
|
||||
|
||||
var notifyCmd = &cobra.Command{
|
||||
Use: "notify <summary> [body]",
|
||||
Short: "Send a desktop notification",
|
||||
Long: `Send a desktop notification with optional actions.
|
||||
Long: `Send a desktop notification. Flags follow notify-send.
|
||||
|
||||
If --file is provided, the notification will have "Open" and "Open Folder" actions.
|
||||
If --file is provided, the notification gets "Open" and "Open Folder" actions
|
||||
that open the file or its directory when clicked.
|
||||
|
||||
Examples:
|
||||
dms notify "Hello" "World"
|
||||
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone
|
||||
dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`,
|
||||
dms notify -u critical -t 0 "Disk almost full"
|
||||
dms notify -a "Backup" -i drive-harddisk -c transfer.complete "Backup finished"
|
||||
dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg
|
||||
dms notify -A retry=Retry -A "Dismiss" "Build failed"
|
||||
dms notify -h string:x-dms-tag:build -h int:value:42 "Hinted"`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: runNotify,
|
||||
}
|
||||
@@ -39,38 +59,229 @@ var genericNotifyActionCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var notifyFlagAliases = map[string]string{
|
||||
"app": "app-name",
|
||||
"timeout": "expire-time",
|
||||
}
|
||||
|
||||
func init() {
|
||||
notifyCmd.Flags().StringVar(¬ifyAppName, "app", "DMS", "Application name")
|
||||
notifyCmd.Flags().StringVar(¬ifyIcon, "icon", "", "Icon name or path")
|
||||
notifyCmd.Flags().StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)")
|
||||
notifyCmd.Flags().IntVar(¬ifyTimeout, "timeout", 5000, "Timeout in milliseconds")
|
||||
f := notifyCmd.Flags()
|
||||
f.SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
if alias, ok := notifyFlagAliases[name]; ok {
|
||||
return pflag.NormalizedName(alias)
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
// Frees -h for --hint like notify-send; cobra skips its own help flag when one exists.
|
||||
f.Bool("help", false, "Show help")
|
||||
f.StringVarP(¬ifyUrgency, "urgency", "u", "", "Urgency level: low, normal, critical")
|
||||
f.IntVarP(¬ifyTimeout, "expire-time", "t", -1, "Timeout in milliseconds (0 never expires, -1 server default)")
|
||||
f.StringVarP(¬ifyAppName, "app-name", "a", "DMS", "Application name")
|
||||
f.StringVarP(¬ifyIcon, "icon", "i", "", "Icon name or path")
|
||||
f.StringVarP(¬ifyAppIcon, "app-icon", "n", "", "Application icon name or path; --icon then becomes the image")
|
||||
f.StringVar(¬ifyCategory, "category", "", "Notification category, comma separated (-c is taken by the global --config)")
|
||||
f.BoolVarP(¬ifyTransient, "transient", "e", false, "Do not keep the notification in history")
|
||||
f.StringArrayVarP(¬ifyHints, "hint", "h", nil, "Extra hint as TYPE:NAME:VALUE (boolean, int, double, string, byte)")
|
||||
f.BoolVarP(¬ifyPrintID, "print-id", "p", false, "Print the notification id")
|
||||
f.IntVar(¬ifyIDFd, "id-fd", -1, "File descriptor to write the notification id to")
|
||||
f.Uint32VarP(¬ifyReplaceID, "replace-id", "r", 0, "Id of the notification to replace")
|
||||
f.BoolVarP(¬ifyWait, "wait", "w", false, "Wait until the notification closes")
|
||||
f.StringArrayVarP(¬ifyActions, "action", "A", nil, "Action as [NAME=]Text; implies --wait, prints the chosen NAME (repeatable)")
|
||||
f.IntVar(¬ifyActionFd, "selected-action-fd", -1, "File descriptor to write the chosen action to")
|
||||
f.StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)")
|
||||
}
|
||||
|
||||
func runNotify(cmd *cobra.Command, args []string) {
|
||||
summary := args[0]
|
||||
body := ""
|
||||
if len(args) > 1 {
|
||||
body = args[1]
|
||||
n, err := buildNotification(args)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
n := notify.Notification{
|
||||
AppName: notifyAppName,
|
||||
Icon: notifyIcon,
|
||||
Summary: summary,
|
||||
Body: body,
|
||||
FilePath: notifyFile,
|
||||
Timeout: int32(notifyTimeout),
|
||||
wait := notifyWait || len(n.Actions) > 0
|
||||
var waiter *notify.Waiter
|
||||
if wait {
|
||||
waiter, err = notify.NewWaiter()
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
defer waiter.Close()
|
||||
}
|
||||
|
||||
id, err := notify.Send(n)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
fail(err)
|
||||
}
|
||||
if n.FilePath == "" {
|
||||
reportID(id)
|
||||
|
||||
if n.FilePath != "" {
|
||||
watchNotificationAction(id, n.FilePath)
|
||||
}
|
||||
if !wait {
|
||||
return
|
||||
}
|
||||
watchNotificationAction(id, n.FilePath)
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
res := waiter.Wait(id, waitTimeout())
|
||||
if res.TimedOut {
|
||||
fmt.Fprintln(os.Stderr, "Wait timeout expired")
|
||||
return
|
||||
}
|
||||
if !res.Invoked {
|
||||
return
|
||||
}
|
||||
fmt.Println(res.Action)
|
||||
writeFd(notifyActionFd, res.Action)
|
||||
}
|
||||
|
||||
func buildNotification(args []string) (notify.Notification, error) {
|
||||
n := notify.Notification{
|
||||
AppName: notifyAppName,
|
||||
AppIcon: notifyAppIcon,
|
||||
Icon: notifyIcon,
|
||||
Summary: args[0],
|
||||
FilePath: notifyFile,
|
||||
Timeout: int32(notifyTimeout),
|
||||
ReplaceID: notifyReplaceID,
|
||||
Hints: map[string]dbus.Variant{},
|
||||
}
|
||||
if len(args) > 1 {
|
||||
n.Body = args[1]
|
||||
}
|
||||
if notifyFile != "" && len(notifyActions) > 0 {
|
||||
return n, fmt.Errorf("--file and --action cannot be combined")
|
||||
}
|
||||
if notifyUrgency != "" {
|
||||
level, err := parseUrgency(notifyUrgency)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
n.Hints["urgency"] = dbus.MakeVariant(level)
|
||||
}
|
||||
if notifyCategory != "" {
|
||||
n.Hints["category"] = dbus.MakeVariant(notifyCategory)
|
||||
}
|
||||
if notifyTransient {
|
||||
n.Hints["transient"] = dbus.MakeVariant(true)
|
||||
}
|
||||
for _, spec := range notifyHints {
|
||||
name, value, err := parseHint(spec)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
n.Hints[name] = value
|
||||
}
|
||||
n.Actions = parseActions(notifyActions)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseUrgency(s string) (byte, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "low", "0":
|
||||
return 0, nil
|
||||
case "normal", "1":
|
||||
return 1, nil
|
||||
case "critical", "2":
|
||||
return 2, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid urgency %q: use low, normal or critical", s)
|
||||
}
|
||||
|
||||
func parseHint(spec string) (string, dbus.Variant, error) {
|
||||
parts := strings.SplitN(spec, ":", 3)
|
||||
if len(parts) != 3 || parts[1] == "" {
|
||||
return "", dbus.Variant{}, fmt.Errorf("invalid hint %q: expected TYPE:NAME:VALUE", spec)
|
||||
}
|
||||
typ, name, value := strings.ToLower(parts[0]), parts[1], parts[2]
|
||||
switch typ {
|
||||
case "string":
|
||||
return name, dbus.MakeVariant(value), nil
|
||||
case "int":
|
||||
v, err := strconv.ParseInt(value, 10, 32)
|
||||
if err != nil {
|
||||
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid int %q", name, value)
|
||||
}
|
||||
return name, dbus.MakeVariant(int32(v)), nil
|
||||
case "double":
|
||||
v, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid double %q", name, value)
|
||||
}
|
||||
return name, dbus.MakeVariant(v), nil
|
||||
case "byte":
|
||||
v, err := strconv.ParseUint(value, 0, 8)
|
||||
if err != nil {
|
||||
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid byte %q", name, value)
|
||||
}
|
||||
return name, dbus.MakeVariant(byte(v)), nil
|
||||
case "boolean":
|
||||
v, err := parseBoolean(value)
|
||||
if err != nil {
|
||||
return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid boolean %q", name, value)
|
||||
}
|
||||
return name, dbus.MakeVariant(v), nil
|
||||
}
|
||||
return "", dbus.Variant{}, fmt.Errorf("invalid hint type %q: use boolean, int, double, string or byte", parts[0])
|
||||
}
|
||||
|
||||
func parseBoolean(s string) (bool, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
}
|
||||
v, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return v != 0, nil
|
||||
}
|
||||
|
||||
func parseActions(specs []string) []string {
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
actions := make([]string, 0, len(specs)*2)
|
||||
for i, spec := range specs {
|
||||
name, label, hasName := strings.Cut(spec, "=")
|
||||
if !hasName {
|
||||
name, label = strconv.Itoa(i), spec
|
||||
}
|
||||
actions = append(actions, name, label)
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
func waitTimeout() time.Duration {
|
||||
if notifyTimeout <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(notifyTimeout) * time.Millisecond
|
||||
}
|
||||
|
||||
func reportID(id uint32) {
|
||||
if notifyPrintID {
|
||||
fmt.Println(id)
|
||||
}
|
||||
writeFd(notifyIDFd, strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
|
||||
func writeFd(fd int, line string) {
|
||||
if fd < 0 {
|
||||
return
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), "")
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(f, line)
|
||||
_ = f.Sync()
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func watchNotificationAction(id uint32, path string) {
|
||||
|
||||
+117
-70
@@ -22,21 +22,26 @@ const (
|
||||
notifyPath = "/org/freedesktop/Notifications"
|
||||
notifyInterface = "org.freedesktop.Notifications"
|
||||
|
||||
maxSummaryLen = 29
|
||||
maxBodyLen = 80
|
||||
signalClosed = notifyInterface + ".NotificationClosed"
|
||||
signalActionInvoked = notifyInterface + ".ActionInvoked"
|
||||
|
||||
listenerMaxLifetime = time.Hour
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
AppName string
|
||||
AppName string
|
||||
// Notify app_icon argument. Falls back to Icon.
|
||||
AppIcon string
|
||||
Icon string
|
||||
Summary string
|
||||
Body string
|
||||
FilePath string
|
||||
Timeout int32
|
||||
// Milliseconds; 0 never expires, -1 lets the server decide.
|
||||
Timeout int32
|
||||
ReplaceID uint32
|
||||
// Pairs of id, label; ignored when FilePath is set.
|
||||
Actions []string
|
||||
Hints map[string]dbus.Variant
|
||||
}
|
||||
|
||||
func Send(n Notification) (uint32, error) {
|
||||
@@ -51,12 +56,6 @@ func Send(n Notification) (uint32, error) {
|
||||
if n.Icon == "" && n.AppName == appName {
|
||||
n.Icon = AppID
|
||||
}
|
||||
if n.Timeout == 0 {
|
||||
n.Timeout = 5000
|
||||
}
|
||||
|
||||
n.Summary = truncate(n.Summary, maxSummaryLen)
|
||||
n.Body = truncate(n.Body, maxBodyLen)
|
||||
|
||||
actions := n.Actions
|
||||
if n.FilePath != "" {
|
||||
@@ -74,11 +73,17 @@ func Send(n Notification) (uint32, error) {
|
||||
hints["desktop-entry"] = dbus.MakeVariant(AppID)
|
||||
}
|
||||
if n.FilePath != "" {
|
||||
imgPath := n.FilePath
|
||||
if !strings.HasPrefix(imgPath, "file://") {
|
||||
imgPath = "file://" + imgPath
|
||||
hints["image_path"] = dbus.MakeVariant(fileURI(n.FilePath))
|
||||
}
|
||||
appIcon := n.Icon
|
||||
if n.AppIcon != "" {
|
||||
appIcon = n.AppIcon
|
||||
if n.Icon != "" {
|
||||
hints["image-path"] = dbus.MakeVariant(n.Icon)
|
||||
}
|
||||
hints["image_path"] = dbus.MakeVariant(imgPath)
|
||||
}
|
||||
for k, v := range n.Hints {
|
||||
hints[k] = v
|
||||
}
|
||||
|
||||
obj := conn.Object(notifyDest, notifyPath)
|
||||
@@ -86,8 +91,8 @@ func Send(n Notification) (uint32, error) {
|
||||
notifyInterface+".Notify",
|
||||
0,
|
||||
n.AppName,
|
||||
uint32(0),
|
||||
n.Icon,
|
||||
n.ReplaceID,
|
||||
appIcon,
|
||||
n.Summary,
|
||||
n.Body,
|
||||
actions,
|
||||
@@ -107,6 +112,97 @@ func Send(n Notification) (uint32, error) {
|
||||
return notificationID, nil
|
||||
}
|
||||
|
||||
func fileURI(path string) string {
|
||||
if strings.HasPrefix(path, "file://") {
|
||||
return path
|
||||
}
|
||||
return "file://" + path
|
||||
}
|
||||
|
||||
// Waiter subscribes to notification signals. Create it before Send so an
|
||||
// instant action or close cannot slip past the subscription.
|
||||
type Waiter struct {
|
||||
conn *dbus.Conn
|
||||
signals chan *dbus.Signal
|
||||
}
|
||||
|
||||
func NewWaiter() (*Waiter, error) {
|
||||
conn, err := dbus.SessionBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dbus session failed: %w", err)
|
||||
}
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(notifyPath),
|
||||
dbus.WithMatchInterface(notifyInterface),
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("dbus match failed: %w", err)
|
||||
}
|
||||
w := &Waiter{conn: conn, signals: make(chan *dbus.Signal, 10)}
|
||||
conn.Signal(w.signals)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *Waiter) Close() {
|
||||
w.conn.RemoveSignal(w.signals)
|
||||
_ = w.conn.RemoveMatchSignal(
|
||||
dbus.WithMatchObjectPath(notifyPath),
|
||||
dbus.WithMatchInterface(notifyInterface),
|
||||
)
|
||||
}
|
||||
|
||||
type WaitResult struct {
|
||||
Action string
|
||||
Invoked bool
|
||||
TimedOut bool
|
||||
}
|
||||
|
||||
// Blocks until the notification closes or one of its actions fires.
|
||||
// A timeout of zero or less waits forever.
|
||||
func (w *Waiter) Wait(id uint32, timeout time.Duration) WaitResult {
|
||||
var deadline <-chan time.Time
|
||||
if timeout > 0 {
|
||||
deadline = time.After(timeout)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
return WaitResult{TimedOut: true}
|
||||
case sig, ok := <-w.signals:
|
||||
if !ok {
|
||||
return WaitResult{}
|
||||
}
|
||||
res, done := matchSignal(sig, id)
|
||||
if done {
|
||||
return res
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func matchSignal(sig *dbus.Signal, id uint32) (WaitResult, bool) {
|
||||
if sig == nil || len(sig.Body) < 1 {
|
||||
return WaitResult{}, false
|
||||
}
|
||||
sigID, ok := sig.Body[0].(uint32)
|
||||
if !ok || sigID != id {
|
||||
return WaitResult{}, false
|
||||
}
|
||||
switch sig.Name {
|
||||
case signalClosed:
|
||||
return WaitResult{}, true
|
||||
case signalActionInvoked:
|
||||
if len(sig.Body) < 2 {
|
||||
return WaitResult{}, false
|
||||
}
|
||||
action, ok := sig.Body[1].(string)
|
||||
if !ok {
|
||||
return WaitResult{}, false
|
||||
}
|
||||
return WaitResult{Action: action, Invoked: true}, true
|
||||
}
|
||||
return WaitResult{}, false
|
||||
}
|
||||
|
||||
func SpawnActionListener(notificationID uint32, filePath string) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -132,57 +228,17 @@ func RunActionListener(args []string) {
|
||||
|
||||
filePath := args[1]
|
||||
|
||||
conn, err := dbus.SessionBus()
|
||||
w, err := NewWaiter()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
if err := conn.AddMatchSignal(
|
||||
dbus.WithMatchObjectPath(notifyPath),
|
||||
dbus.WithMatchInterface(notifyInterface),
|
||||
); err != nil {
|
||||
res := w.Wait(uint32(notificationID), listenerMaxLifetime)
|
||||
if !res.Invoked {
|
||||
return
|
||||
}
|
||||
|
||||
signals := make(chan *dbus.Signal, 10)
|
||||
conn.Signal(signals)
|
||||
deadline := time.After(listenerMaxLifetime)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
return
|
||||
case sig := <-signals:
|
||||
if sig == nil || handleSignal(sig, uint32(notificationID), filePath) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSignal(sig *dbus.Signal, notificationID uint32, filePath string) bool {
|
||||
if len(sig.Body) < 1 {
|
||||
return false
|
||||
}
|
||||
id, ok := sig.Body[0].(uint32)
|
||||
if !ok || id != notificationID {
|
||||
return false
|
||||
}
|
||||
switch sig.Name {
|
||||
case notifyInterface + ".NotificationClosed":
|
||||
return true
|
||||
case notifyInterface + ".ActionInvoked":
|
||||
if len(sig.Body) < 2 {
|
||||
return false
|
||||
}
|
||||
action, ok := sig.Body[1].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
handleAction(action, filePath)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
handleAction(res.Action, filePath)
|
||||
}
|
||||
|
||||
func handleAction(action, filePath string) {
|
||||
@@ -201,12 +257,3 @@ func openPath(path string) {
|
||||
}
|
||||
cmd.Start()
|
||||
}
|
||||
|
||||
// Cuts on rune boundaries; a byte slice mid-character makes the dbus encoder reject the string.
|
||||
func truncate(s string, maxRunes int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string(r[:maxRunes-3]) + "..."
|
||||
}
|
||||
@@ -33,6 +33,13 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
|
||||
if !strings.Contains(content, "Qt.ImhSensitiveData") {
|
||||
t.Fatalf("imeCommitSink must advertise hidden-text hints so IMEs treat it as a password field")
|
||||
}
|
||||
|
||||
// fcitx5-qt parents its candidate xdg_popup to the focus window, and an
|
||||
// ext-session-lock surface cannot parent popups (#3669).
|
||||
sinkGate := regexp.MustCompile(`(?s)Loader\s*\{[^{}]*active:\s*KeyUtils\.compositorTextInputSelected\([^{}]*sourceComponent:\s*TextInput\s*\{\s*id:\s*imeCommitSink`)
|
||||
if !sinkGate.MatchString(content) {
|
||||
t.Fatalf("imeCommitSink must only load when Qt uses its wayland text-input context (#3669)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockScreenFprintProfileMatchesPamQml(t *testing.T) {
|
||||
|
||||
@@ -47,6 +47,7 @@ func handleSend(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) {
|
||||
Summary: summary,
|
||||
Body: params.StringOpt(req.Params, "body", ""),
|
||||
Icon: params.StringOpt(req.Params, "icon", ""),
|
||||
Timeout: 5000,
|
||||
}
|
||||
label := params.StringOpt(req.Params, "actionLabel", "")
|
||||
var argv []string
|
||||
|
||||
Reference in new issue
Block a user