|
|
@@ -96,12 +96,47 @@ func profileFileName(name string) string {
|
|
|
return sanitizeFilename(name) + ".json"
|
|
|
}
|
|
|
|
|
|
+// resolveProfileTarget maps one profile argument to the file it names, and to the
|
|
|
+// name a profile written there should carry.
|
|
|
+//
|
|
|
+// An argument ending in .json is a path and is used exactly as given, so
|
|
|
+// `load profiles/lava.json` reads that file and `save ./lava.json` writes it. It
|
|
|
+// is not a search: the argument names the file, so there is nothing to search
|
|
|
+// for, and the same argument names the same file from any working directory.
|
|
|
+// The suffix is matched without regard to case so the rule is the same on Linux,
|
|
|
+// macOS and Windows, and the file system rather than this code decides whether the
|
|
|
+// case is right. No separator is looked for — the Windows file API takes both `/`
|
|
|
+// and `\`, so a check for either would be a platform difference with no behaviour
|
|
|
+// behind it — and nothing here joins the path to a directory, so a path the
|
|
|
+// operating system rejects fails as itself rather than as a name.
|
|
|
+//
|
|
|
+// Every other argument is a profile name, and a name lives in the per-user
|
|
|
+// directory: profilesPath and the sanitized file name. A name is the only way in
|
|
|
+// there, and an argument that names a file is never also a name — which is what
|
|
|
+// keeps a .json argument from also resolving to `lala-json.json`.
|
|
|
+func resolveProfileTarget(arg string) (path string, name string, isPath bool) {
|
|
|
+ if !strings.HasSuffix(strings.ToLower(arg), ".json") {
|
|
|
+ return filepath.Join(profilesPath(), profileFileName(arg)), arg, false
|
|
|
+ }
|
|
|
+ base := filepath.Base(arg)
|
|
|
+ return arg, strings.TrimSuffix(base, filepath.Ext(base)), true
|
|
|
+}
|
|
|
+
|
|
|
+// Save writes the profile into the per-user directory under its own name, which is
|
|
|
+// what every caller that has a name and no path means.
|
|
|
func (p *Profile) Save() error {
|
|
|
+ return p.saveTo(filepath.Join(profilesPath(), profileFileName(p.Name)))
|
|
|
+}
|
|
|
+
|
|
|
+// saveTo writes the profile to one file and creates the directory it is in. It
|
|
|
+// resolves nothing: the caller decides where the file goes, so that the rule that
|
|
|
+// decides is the only one there is.
|
|
|
+func (p *Profile) saveTo(path string) error {
|
|
|
if p.Name == "" {
|
|
|
return fmt.Errorf("profile name is required")
|
|
|
}
|
|
|
|
|
|
- if err := os.MkdirAll(profilesPath(), 0o755); err != nil {
|
|
|
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
|
return fmt.Errorf("create profiles directory: %w", err)
|
|
|
}
|
|
|
|
|
|
@@ -110,7 +145,6 @@ func (p *Profile) Save() error {
|
|
|
return fmt.Errorf("marshal profile: %w", err)
|
|
|
}
|
|
|
|
|
|
- path := filepath.Join(profilesPath(), profileFileName(p.Name))
|
|
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
|
return fmt.Errorf("write profile: %w", err)
|
|
|
}
|
|
|
@@ -118,7 +152,23 @@ func (p *Profile) Save() error {
|
|
|
}
|
|
|
|
|
|
func LoadProfile(name string) (*Profile, error) {
|
|
|
- path := filepath.Join(profilesPath(), profileFileName(name))
|
|
|
+ path, _, isPath := resolveProfileTarget(name)
|
|
|
+
|
|
|
+ // A path is stat'd before it is read, so a directory says it is one. Reading a
|
|
|
+ // directory fails anyway, and reporting that as "not found" would be the one
|
|
|
+ // answer a user cannot act on.
|
|
|
+ if isPath {
|
|
|
+ info, err := os.Stat(path)
|
|
|
+ switch {
|
|
|
+ case os.IsNotExist(err):
|
|
|
+ return nil, fmt.Errorf("profile file %s not found", path)
|
|
|
+ case err != nil:
|
|
|
+ return nil, fmt.Errorf("read profile file %s: %w", path, err)
|
|
|
+ case info.IsDir():
|
|
|
+ return nil, fmt.Errorf("profile file %s is a directory", path)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
data, err := os.ReadFile(path)
|
|
|
if err != nil {
|
|
|
if os.IsNotExist(err) {
|
|
|
@@ -208,8 +258,9 @@ func applyProfileToProfile(proto rgbProtocol, channels []via.Channel, display ma
|
|
|
// loadProfileFromDevice reads RGB state from device and saves it. Every channel
|
|
|
// is recorded, so the profile a `load` applies later is the whole keyboard and
|
|
|
// not the part of it that happened to be named. Warnings go to warn, which is the
|
|
|
-// command's stderr.
|
|
|
-func loadProfileFromDevice(name string, warn io.Writer) error {
|
|
|
+// command's stderr. The file is written where path says and the profile is named
|
|
|
+// after name, so a `save` of a file writes that file and names the profile for it.
|
|
|
+func loadProfileFromDevice(path, name string, warn io.Writer) error {
|
|
|
proto, target, channels, err := openTarget("")
|
|
|
if err != nil {
|
|
|
return err
|
|
|
@@ -239,26 +290,42 @@ func loadProfileFromDevice(name string, warn io.Writer) error {
|
|
|
if err := applyProfileToProfile(proto, channels, target.Display, catalog, p); err != nil {
|
|
|
return err
|
|
|
}
|
|
|
- return p.Save()
|
|
|
+ return p.saveTo(path)
|
|
|
+}
|
|
|
+
|
|
|
+// savedProfileLine is what a save says it did. The path is annotated the way every
|
|
|
+// other message here is, and the name is in the line because the file is named
|
|
|
+// after it: a save that only reported success would be silent about where a file
|
|
|
+// it was told to write by path actually went, which is the one thing about a save
|
|
|
+// worth reporting.
|
|
|
+func savedProfileLine(name, path string) string {
|
|
|
+ return fmt.Sprintf("Saved profile %s to %s\n", name, describeDataDir(path))
|
|
|
}
|
|
|
|
|
|
func NewProfileSaveCmd() *cobra.Command {
|
|
|
return &cobra.Command{
|
|
|
- Use: "save [name]",
|
|
|
+ Use: "save [name|file]",
|
|
|
Short: "Save current RGB state to a profile",
|
|
|
Long: "Read the current RGB settings from every channel of the keyboard and save\n" +
|
|
|
- "them as a JSON profile in the profiles/ directory.\n" +
|
|
|
+ "them as a JSON profile. A name is written as <name>.json in the per-user\n" +
|
|
|
+ "profiles/ directory; an argument ending in .json is a path, and that file is\n" +
|
|
|
+ "written where it says. It reports the file it wrote.\n" +
|
|
|
"\n" +
|
|
|
"A profile is always complete. Recording only some of the channels would let a\n" +
|
|
|
"later `load` apply them and leave the rest as they were, which reads as a\n" +
|
|
|
"zone the profile had nothing to say about.",
|
|
|
Args: cobra.MaximumNArgs(1),
|
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
|
- name := "default"
|
|
|
+ arg := "default"
|
|
|
if len(args) > 0 {
|
|
|
- name = args[0]
|
|
|
+ arg = args[0]
|
|
|
}
|
|
|
- return loadProfileFromDevice(name, cmd.ErrOrStderr())
|
|
|
+ path, name, _ := resolveProfileTarget(arg)
|
|
|
+ if err := loadProfileFromDevice(path, name, cmd.ErrOrStderr()); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ fmt.Fprint(cmd.OutOrStdout(), savedProfileLine(name, path))
|
|
|
+ return nil
|
|
|
},
|
|
|
}
|
|
|
}
|
|
|
@@ -268,13 +335,14 @@ func NewProfileLoadCmd() *cobra.Command {
|
|
|
// the zone completion only applies once the name has been typed.
|
|
|
cmd := &cobra.Command{
|
|
|
ValidArgsFunction: completeLoadArgs,
|
|
|
- Use: "load <name> [zone]",
|
|
|
+ Use: "load <name|file> [zone]",
|
|
|
Short: "Load a profile and apply it to the keyboard",
|
|
|
- Long: "Read a JSON profile from the profiles/ directory and apply the saved RGB\n" +
|
|
|
- "settings to the keyboard. Without a zone the profile is applied to every\n" +
|
|
|
- "channel it names.",
|
|
|
+ Long: "Read a JSON profile and apply the saved RGB settings to the keyboard. A\n" +
|
|
|
+ "name is read from the per-user profiles/ directory; an argument ending in\n" +
|
|
|
+ ".json is a path, and that file is read where it says. Without a zone the\n" +
|
|
|
+ "profile is applied to every channel it names.",
|
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
|
- name := args[0]
|
|
|
+ arg := args[0]
|
|
|
zone := ""
|
|
|
if len(args) == 2 {
|
|
|
zone = args[1]
|
|
|
@@ -286,7 +354,7 @@ func NewProfileLoadCmd() *cobra.Command {
|
|
|
}
|
|
|
defer proto.Close()
|
|
|
|
|
|
- p, err := LoadProfile(name)
|
|
|
+ p, err := LoadProfile(arg)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
@@ -337,7 +405,7 @@ func NewProfileLoadCmd() *cobra.Command {
|
|
|
if catalog == nil {
|
|
|
fmt.Fprintf(cmd.ErrOrStderr(),
|
|
|
"Warning: profile %q has no effect names for this keyboard, so nothing applied; "+
|
|
|
- "run `keyboard fetch` for its VIA definition\n", name)
|
|
|
+ "run `keyboard fetch` for its VIA definition\n", arg)
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
@@ -347,7 +415,7 @@ func NewProfileLoadCmd() *cobra.Command {
|
|
|
keyChannels, err := resolveZoneName(key, target.Display, target.Alternatives)
|
|
|
if err != nil {
|
|
|
fmt.Fprintf(cmd.ErrOrStderr(),
|
|
|
- "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
|
|
|
+ "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", arg, key)
|
|
|
continue
|
|
|
}
|
|
|
|
|
|
@@ -360,7 +428,7 @@ func NewProfileLoadCmd() *cobra.Command {
|
|
|
}
|
|
|
if !placed {
|
|
|
fmt.Fprintf(cmd.ErrOrStderr(),
|
|
|
- "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
|
|
|
+ "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", arg, key)
|
|
|
continue
|
|
|
}
|
|
|
|
|
|
@@ -416,12 +484,12 @@ func NewProfileLoadCmd() *cobra.Command {
|
|
|
|
|
|
if applied == 0 {
|
|
|
fmt.Fprintf(cmd.ErrOrStderr(),
|
|
|
- "Warning: profile %q has no settings for the selected zone(s); nothing applied\n", name)
|
|
|
+ "Warning: profile %q has no settings for the selected zone(s); nothing applied\n", arg)
|
|
|
}
|
|
|
return nil
|
|
|
},
|
|
|
}
|
|
|
- cmd.Args = zoneArgs(1, 2, "a profile name and at most a zone")
|
|
|
+ cmd.Args = zoneArgs(1, 2, "a profile name or file and at most a zone")
|
|
|
return cmd
|
|
|
}
|
|
|
|