@ -0,0 +1,4 @@ |
|||
build |
|||
build/**/* |
|||
.idea/ |
|||
.vscode/ |
@ -0,0 +1,3 @@ |
|||
{ |
|||
"esversion": 6 |
|||
} |
@ -0,0 +1,76 @@ |
|||
# Contributor Covenant Code of Conduct |
|||
|
|||
## Our Pledge |
|||
|
|||
In the interest of fostering an open and welcoming environment, we as |
|||
contributors and maintainers pledge to making participation in our project and |
|||
our community a harassment-free experience for everyone, regardless of age, body |
|||
size, disability, ethnicity, sex characteristics, gender identity and expression, |
|||
level of experience, education, socio-economic status, nationality, personal |
|||
appearance, race, religion, or sexual identity and orientation. |
|||
|
|||
## Our Standards |
|||
|
|||
Examples of behavior that contributes to creating a positive environment |
|||
include: |
|||
|
|||
* Using welcoming and inclusive language |
|||
* Being respectful of differing viewpoints and experiences |
|||
* Gracefully accepting constructive criticism |
|||
* Focusing on what is best for the community |
|||
* Showing empathy towards other community members |
|||
|
|||
Examples of unacceptable behavior by participants include: |
|||
|
|||
* The use of sexualized language or imagery and unwelcome sexual attention or |
|||
advances |
|||
* Trolling, insulting/derogatory comments, and personal or political attacks |
|||
* Public or private harassment |
|||
* Publishing others' private information, such as a physical or electronic |
|||
address, without explicit permission |
|||
* Other conduct which could reasonably be considered inappropriate in a |
|||
professional setting |
|||
|
|||
## Our Responsibilities |
|||
|
|||
Project maintainers are responsible for clarifying the standards of acceptable |
|||
behavior and are expected to take appropriate and fair corrective action in |
|||
response to any instances of unacceptable behavior. |
|||
|
|||
Project maintainers have the right and responsibility to remove, edit, or |
|||
reject comments, commits, code, wiki edits, issues, and other contributions |
|||
that are not aligned to this Code of Conduct, or to ban temporarily or |
|||
permanently any contributor for other behaviors that they deem inappropriate, |
|||
threatening, offensive, or harmful. |
|||
|
|||
## Scope |
|||
|
|||
This Code of Conduct applies both within project spaces and in public spaces |
|||
when an individual is representing the project or its community. Examples of |
|||
representing a project or community include using an official project e-mail |
|||
address, posting via an official social media account, or acting as an appointed |
|||
representative at an online or offline event. Representation of a project may be |
|||
further defined and clarified by project maintainers. |
|||
|
|||
## Enforcement |
|||
|
|||
Instances of abusive, harassing, or otherwise unacceptable behavior may be |
|||
reported by contacting the project team at flyweight@pm.me. All |
|||
complaints will be reviewed and investigated and will result in a response that |
|||
is deemed necessary and appropriate to the circumstances. The project team is |
|||
obligated to maintain confidentiality with regard to the reporter of an incident. |
|||
Further details of specific enforcement policies may be posted separately. |
|||
|
|||
Project maintainers who do not follow or enforce the Code of Conduct in good |
|||
faith may face temporary or permanent repercussions as determined by other |
|||
members of the project's leadership. |
|||
|
|||
## Attribution |
|||
|
|||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, |
|||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html |
|||
|
|||
[homepage]: https://www.contributor-covenant.org |
|||
|
|||
For answers to common questions about this code of conduct, see |
|||
https://www.contributor-covenant.org/faq |
@ -1,6 +1,6 @@ |
|||
MIT License |
|||
|
|||
Copyright (c) 2019-Present Vito |
|||
Copyright (c) 2020 Christopher Murphy |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|||
of this software and associated documentation files (the "Software"), to deal |
@ -0,0 +1,48 @@ |
|||
![Optimus logo](./.github/optimus-logo--960x540.png) |
|||
|
|||
# Optimus |
|||
|
|||
> Image compression, optimization and conversion desktop app. |
|||
|
|||
## Overview |
|||
|
|||
Optimus is a desktop image optimization application. It supports conversion and compression between WebP, JPEG, and PNG image formats. |
|||
|
|||
## Features |
|||
|
|||
- Convert to and from JPEG, PNG, and WebP formats. |
|||
- Compress JPEG, PNG (lossy), and WebP (lossy and lossless) formats. |
|||
- Resize images to various sizes in a single batch operation. |
|||
- View simple stats on session and all-time use. |
|||
|
|||
![Screenshot of Optimus primary image editor view](./.github/optimus_screenshot_editor--1200x742.png) |
|||
|
|||
![Screenshot of Optimus options view](./.github/optimus_screenshot_options--1200x936.png) |
|||
|
|||
## Installation |
|||
|
|||
### Downloads |
|||
|
|||
Download the latest version from the [releases page](https://github.com/Splode/optimus/releases). |
|||
|
|||
Optimus is available for Windows, macOS, and Linux. |
|||
|
|||
### Scoop |
|||
|
|||
```bash |
|||
scoop install https://raw.githubusercontent.com/Splode/optimus/main/optimus.json |
|||
``` |
|||
|
|||
## Development |
|||
|
|||
Optimus is built using [Wails](https://wails.app/) and uses JavaScript on the frontend and Go on the backend. |
|||
|
|||
Take the following steps to develop locally: |
|||
|
|||
1. Clone the repo |
|||
2. Install Wails |
|||
3. Install go and npm dependencies |
|||
|
|||
## License |
|||
|
|||
MIT © 2020 Christopher Murphy |
After Width: | Height: | Size: 92 KiB |
After Width: | Height: | Size: 222 KiB |
@ -0,0 +1,188 @@ |
|||
package config |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"github.com/wailsapp/wails" |
|||
"optimus/backend/jpeg" |
|||
"optimus/backend/localstore" |
|||
"optimus/backend/png" |
|||
"optimus/backend/webp" |
|||
"os" |
|||
"path" |
|||
"path/filepath" |
|||
"strconv" |
|||
) |
|||
|
|||
const filename = "conf.json" |
|||
|
|||
// App represents application persistent configuration values.
|
|||
type App struct { |
|||
OutDir string `json:"outDir"` |
|||
Target string `json:"target"` |
|||
Prefix string `json:"prefix"` |
|||
Suffix string `json:"suffix"` |
|||
Sizes []*size `json:"sizes"` |
|||
JpegOpt *jpeg.Options `json:"jpegOpt"` |
|||
PngOpt *png.Options `json:"pngOpt"` |
|||
WebpOpt *webp.Options `json:"webpOpt"` |
|||
} |
|||
|
|||
// Config represents the application settings.
|
|||
type Config struct { |
|||
App *App |
|||
Runtime *wails.Runtime |
|||
Logger *wails.CustomLogger |
|||
localStore *localstore.LocalStore |
|||
} |
|||
|
|||
// WailsInit performs setup when Wails is ready.
|
|||
func (c *Config) WailsInit(runtime *wails.Runtime) error { |
|||
c.Runtime = runtime |
|||
c.Logger = c.Runtime.Log.New("Config") |
|||
c.Logger.Info("Config initialized...") |
|||
return nil |
|||
} |
|||
|
|||
// NewConfig returns a new instance of Config.
|
|||
func NewConfig() *Config { |
|||
c := &Config{} |
|||
c.localStore = localstore.NewLocalStore() |
|||
|
|||
a, err := c.localStore.Load(filename) |
|||
if err != nil { |
|||
c.App, _ = defaults() |
|||
} |
|||
if err = json.Unmarshal(a, &c.App); err != nil { |
|||
fmt.Printf("error") |
|||
} |
|||
return c |
|||
} |
|||
|
|||
// GetAppConfig returns the application configuration.
|
|||
func (c *Config) GetAppConfig() map[string]interface{} { |
|||
return map[string]interface{}{ |
|||
"outDir": c.App.OutDir, |
|||
"target": c.App.Target, |
|||
"prefix": c.App.Prefix, |
|||
"suffix": c.App.Suffix, |
|||
"sizes": c.App.Sizes, |
|||
"jpegOpt": c.App.JpegOpt, |
|||
"pngOpt": c.App.PngOpt, |
|||
"webpOpt": c.App.WebpOpt, |
|||
} |
|||
} |
|||
|
|||
// OpenOutputDir opens the output directory using the native system browser.
|
|||
func (c *Config) OpenOutputDir() error { |
|||
if err := c.Runtime.Browser.OpenURL(c.App.OutDir); err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// RestoreDefaults sets the app configuration to defaults.
|
|||
func (c *Config) RestoreDefaults() (err error) { |
|||
var a *App |
|||
a, err = defaults() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
c.App = a |
|||
if err = c.store(); err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// SetConfig sets and stores the given configuration.
|
|||
func (c *Config) SetConfig(cfg string) error { |
|||
a := &App{} |
|||
if err := json.Unmarshal([]byte(cfg), &a); err != nil { |
|||
c.Logger.Errorf("failed to unmarshal config: %v", err) |
|||
return err |
|||
} |
|||
c.App = a |
|||
if err := c.store(); err != nil { |
|||
c.Logger.Errorf("failed to store config: %v", err) |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// SetOutDir opens a directory select dialog and sets the output directory to
|
|||
// the chosen directory.
|
|||
func (c *Config) SetOutDir() string { |
|||
dir := c.Runtime.Dialog.SelectDirectory() |
|||
if dir != "" { |
|||
c.App.OutDir = dir |
|||
c.Logger.Infof("set output directory: %s", dir) |
|||
if err := c.store(); err != nil { |
|||
c.Logger.Errorf("failed to store config: %v", err) |
|||
} |
|||
} |
|||
return c.App.OutDir |
|||
} |
|||
|
|||
// defaults returns the application configuration defaults.
|
|||
func defaults() (*App, error) { |
|||
a := &App{ |
|||
Target: "webp", |
|||
JpegOpt: &jpeg.Options{Quality: 80}, |
|||
PngOpt: &png.Options{Quality: 80}, |
|||
WebpOpt: &webp.Options{Lossless: false, Quality: 80}, |
|||
} |
|||
ud, err := os.UserHomeDir() |
|||
if err != nil { |
|||
fmt.Printf("failed to get user directory: %v", err) |
|||
return nil, err |
|||
} |
|||
|
|||
od := path.Join(ud, "Optimus") |
|||
cp := filepath.Clean(od) |
|||
|
|||
if _, err = os.Stat(od); os.IsNotExist(err) { |
|||
if err = os.Mkdir(od, 0777); err != nil { |
|||
od = "./" |
|||
fmt.Printf("failed to create default output directory: %v", err) |
|||
return nil, err |
|||
} |
|||
} |
|||
a.OutDir = cp |
|||
return a, nil |
|||
} |
|||
|
|||
// store stores the configuration state to the file system.
|
|||
func (c *Config) store() error { |
|||
js, err := json.Marshal(c.GetAppConfig()) |
|||
if err != nil { |
|||
c.Logger.Errorf("failed to marshal config: %v", err) |
|||
return err |
|||
} |
|||
if err = c.localStore.Store(js, filename); err != nil { |
|||
c.Logger.Errorf("failed to store config: %v", err) |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// rect represents an image width and height size.
|
|||
type rect struct { |
|||
Height int `json:"height,omitempty"` |
|||
Width int `json:"width,omitempty"` |
|||
} |
|||
|
|||
// String returns a string representation of the rect.
|
|||
// For example, "1280x720"
|
|||
func (r *rect) String() string { |
|||
w := strconv.Itoa(r.Width) |
|||
h := strconv.Itoa(r.Height) |
|||
return fmt.Sprintf("%sx%s", w, h) |
|||
} |
|||
|
|||
// size represents an image resizing. Strategy represents an image resizing
|
|||
// strategy, such as cropping.
|
|||
type size struct { |
|||
rect |
|||
Strategy int `json:"strategy"` |
|||
} |
@ -0,0 +1,177 @@ |
|||
package image |
|||
|
|||
import ( |
|||
"bytes" |
|||
"errors" |
|||
"fmt" |
|||
"image" |
|||
"io/ioutil" |
|||
"optimus/backend/config" |
|||
"optimus/backend/jpeg" |
|||
"optimus/backend/png" |
|||
"optimus/backend/webp" |
|||
"os" |
|||
"path" |
|||
"path/filepath" |
|||
|
|||
"github.com/disintegration/imaging" |
|||
"github.com/muesli/smartcrop" |
|||
"github.com/muesli/smartcrop/nfnt" |
|||
"github.com/wailsapp/wails" |
|||
) |
|||
|
|||
const ( |
|||
fill = iota |
|||
fit |
|||
smart |
|||
) |
|||
|
|||
var mimes = map[string]string{ |
|||
"image/.jpg": "jpg", |
|||
"image/jpg": "jpg", |
|||
"image/jpeg": "jpg", |
|||
"image/png": "png", |
|||
"image/webp": "webp", |
|||
} |
|||
|
|||
// File represents an image file.
|
|||
type File struct { |
|||
Data []byte `json:"data"` |
|||
Ext string `json:"ext"` |
|||
ID string `json:"id"` |
|||
MimeType string `json:"type"` |
|||
Name string `json:"name"` |
|||
Size int64 `json:"size"` |
|||
ConvertedFile string |
|||
IsConverted bool |
|||
Image image.Image |
|||
Runtime *wails.Runtime |
|||
} |
|||
|
|||
// Decode decodes the file's data based on its mime type.
|
|||
func (f *File) Decode() error { |
|||
mime, err := getFileType(f.MimeType) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
switch mime { |
|||
case "jpg": |
|||
f.Image, err = jpeg.DecodeJPEG(bytes.NewReader(f.Data)) |
|||
case "png": |
|||
f.Image, err = png.DecodePNG(bytes.NewReader(f.Data)) |
|||
case "webp": |
|||
f.Image, err = webp.DecodeWebp(bytes.NewReader(f.Data)) |
|||
} |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// GetConvertedSize returns the size of the converted file.
|
|||
func (f *File) GetConvertedSize() (int64, error) { |
|||
if f.ConvertedFile == "" { |
|||
return 0, errors.New("file has no converted file") |
|||
} |
|||
s, err := os.Stat(f.ConvertedFile) |
|||
if err != nil { |
|||
return 0, err |
|||
} |
|||
return s.Size(), nil |
|||
} |
|||
|
|||
// GetSavings returns the delta between original and converted file size.
|
|||
func (f *File) GetSavings() (int64, error) { |
|||
c, err := f.GetConvertedSize() |
|||
if err != nil { |
|||
return 0, err |
|||
} |
|||
return f.Size - c, nil |
|||
} |
|||
|
|||
// Write saves a file to disk based on the encoding target.
|
|||
func (f *File) Write(c *config.Config) error { |
|||
// TODO resizing should probably be in its own method
|
|||
if c.App.Sizes != nil { |
|||
for _, r := range c.App.Sizes { |
|||
if r.Height <= 0 || r.Width <= 0 { |
|||
f.Runtime.Events.Emit("notify", map[string]interface{}{ |
|||
"msg": fmt.Sprintf("Invalid image size: %s", r.String()), |
|||
"type": "warn", |
|||
}) |
|||
continue |
|||
} |
|||
var i image.Image |
|||
var s string |
|||
switch r.Strategy { |
|||
case fill: |
|||
i = imaging.Fill(f.Image, r.Width, r.Height, imaging.Center, imaging.Lanczos) |
|||
s = r.String() |
|||
case fit: |
|||
i = imaging.Fit(f.Image, r.Width, r.Height, imaging.Lanczos) |
|||
s = fmt.Sprintf("%dx%d", i.Bounds().Max.X, i.Bounds().Max.Y) |
|||
case smart: |
|||
analyzer := smartcrop.NewAnalyzer(nfnt.NewDefaultResizer()) |
|||
crop, err := analyzer.FindBestCrop(f.Image, r.Width, r.Height) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
croppedImg := f.Image.(SubImager).SubImage(crop) |
|||
i = imaging.Resize(croppedImg, r.Width, r.Height, imaging.Lanczos) |
|||
s = fmt.Sprintf("%dx%d", i.Bounds().Max.X, i.Bounds().Max.Y) |
|||
} |
|||
buf, err := encToBuf(i, c.App) |
|||
dest := path.Join(c.App.OutDir, c.App.Prefix+f.Name+"--"+s+c.App.Suffix+"."+c.App.Target) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if err = ioutil.WriteFile(dest, buf.Bytes(), 0666); err != nil { |
|||
return err |
|||
} |
|||
} |
|||
} |
|||
buf, err := encToBuf(f.Image, c.App) |
|||
dest := path.Join(c.App.OutDir, c.App.Prefix+f.Name+c.App.Suffix+"."+c.App.Target) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if err = ioutil.WriteFile(dest, buf.Bytes(), 0666); err != nil { |
|||
return err |
|||
} |
|||
f.ConvertedFile = filepath.Clean(dest) |
|||
f.IsConverted = true |
|||
return nil |
|||
} |
|||
|
|||
// encToBuf encodes an image to a buffer using the configured target.
|
|||
func encToBuf(i image.Image, a *config.App) (*bytes.Buffer, error) { |
|||
var b bytes.Buffer |
|||
var err error |
|||
switch a.Target { |
|||
case "jpg": |
|||
b, err = jpeg.EncodeJPEG(i, a.JpegOpt) |
|||
case "png": |
|||
b, err = png.EncodePNG(i, a.PngOpt) |
|||
case "webp": |
|||
b, err = webp.EncodeWebp(i, a.WebpOpt) |
|||
} |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
return &b, nil |
|||
} |
|||
|
|||
// getFileType returns the file's type based on the given mime type.
|
|||
func getFileType(t string) (string, error) { |
|||
m, prs := mimes[t] |
|||
if !prs { |
|||
_ = errors.New("unsupported file type:" + t) |
|||
} |
|||
return m, nil |
|||
} |
|||
|
|||
// SubImager handles creating a subimage from an image rect.
|
|||
type SubImager interface { |
|||
SubImage(r image.Rectangle) image.Image |
|||
} |
@ -0,0 +1,143 @@ |
|||
package image |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"github.com/wailsapp/wails" |
|||
"optimus/backend/config" |
|||
"optimus/backend/stat" |
|||
"runtime/debug" |
|||
"strings" |
|||
"sync" |
|||
"time" |
|||
) |
|||
|
|||
// FileManager handles collections of Files for conversion.
|
|||
type FileManager struct { |
|||
Files []*File |
|||
|
|||
Runtime *wails.Runtime |
|||
Logger *wails.CustomLogger |
|||
|
|||
config *config.Config |
|||
stats *stat.Stat |
|||
} |
|||
|
|||
// NewFileManager creates a new FileManager.
|
|||
func NewFileManager(c *config.Config, s *stat.Stat) *FileManager { |
|||
return &FileManager{ |
|||
config: c, |
|||
stats: s, |
|||
} |
|||
} |
|||
|
|||
// WailsInit performs setup when Wails is ready.
|
|||
func (fm *FileManager) WailsInit(runtime *wails.Runtime) error { |
|||
fm.Runtime = runtime |
|||
fm.Logger = fm.Runtime.Log.New("FileManager") |
|||
fm.Logger.Info("FileManager initialized...") |
|||
return nil |
|||
} |
|||
|
|||
// HandleFile processes a file from the client.
|
|||
func (fm *FileManager) HandleFile(fileJson string) (err error) { |
|||
file := &File{Runtime: fm.Runtime} |
|||
if err = json.Unmarshal([]byte(fileJson), &file); err != nil { |
|||
return err |
|||
} |
|||
|
|||
if err = file.Decode(); err != nil { |
|||
return err |
|||
} |
|||
fm.Files = append(fm.Files, file) |
|||
fm.Logger.Infof("added file to file manager: %s", file.Name) |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// Clear removes the files in the FileManager.
|
|||
func (fm *FileManager) Clear() { |
|||
fm.Files = nil |
|||
debug.FreeOSMemory() |
|||
} |
|||
|
|||
// Convert runs the conversion on all files in the FileManager.
|
|||
func (fm *FileManager) Convert() (errs []error) { |
|||
var wg sync.WaitGroup |
|||
wg.Add(fm.countUnconverted()) |
|||
|
|||
c := 0 |
|||
var b int64 |
|||
t := time.Now().UnixNano() |
|||
for _, file := range fm.Files { |
|||
file := file |
|||
if !file.IsConverted { |
|||
go func(wg *sync.WaitGroup) { |
|||
err := file.Write(fm.config) |
|||
if err != nil { |
|||
fm.Logger.Errorf("failed to convert file: %s, %v", file.ID, err) |
|||
fm.Runtime.Events.Emit("notify", map[string]interface{}{ |
|||
"msg": fmt.Sprintf("Failed to convert file: %s, %s", file.Name, err.Error()), |
|||
"type": "warn", |
|||
}) |
|||
errs = append(errs, fmt.Errorf("failed to convert file: %s", file.Name)) |
|||
} else { |
|||
fm.Logger.Info(fmt.Sprintf("converted file: %s", file.Name)) |
|||
s, err := file.GetConvertedSize() |
|||
if err != nil { |
|||
fm.Logger.Errorf("failed to read converted file size: %v", err) |
|||
} |
|||
fm.Runtime.Events.Emit("conversion:complete", map[string]interface{}{ |
|||
"id": file.ID, |
|||
// TODO: standardize this path conversion
|
|||
"path": strings.Replace(file.ConvertedFile, "\\", "/", -1), |
|||
"size": s, |
|||
}) |
|||
c++ |
|||
s, err = file.GetSavings() |
|||
if err != nil { |
|||
fm.Logger.Errorf("failed to get file conversion savings: %v", err) |
|||
} |
|||
b += s |
|||
} |
|||
wg.Done() |
|||
}(&wg) |
|||
} |
|||
} |
|||
|
|||
wg.Wait() |
|||
nt := (time.Now().UnixNano() - t) / 1000000 |
|||
fm.stats.SetImageCount(c) |
|||
fm.stats.SetByteCount(b) |
|||
fm.stats.SetTimeCount(nt) |
|||
fm.Runtime.Events.Emit("conversion:stat", map[string]interface{}{ |
|||
"count": c, |
|||
"resizes": c * len(fm.config.App.Sizes), |
|||
"savings": b, |
|||
"time": nt, |
|||
}) |
|||
fm.Clear() |
|||
return errs |
|||
} |
|||
|
|||
// OpenFile opens the file at the given filepath using the file's native file
|
|||
// application.
|
|||
func (fm *FileManager) OpenFile(p string) error { |
|||
if err := fm.Runtime.Browser.OpenFile(p); err != nil { |
|||
fm.Logger.Errorf("failed to open file %s: %v", p, err) |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// countUnconverted returns the number of files in the FileManager that haven't
|
|||
// been converted.
|
|||
func (fm *FileManager) countUnconverted() int { |
|||
c := 0 |
|||
for _, file := range fm.Files { |
|||
if !file.IsConverted { |
|||
c++ |
|||
} |
|||
} |
|||
return c |
|||
} |
@ -0,0 +1,28 @@ |
|||
package jpeg |
|||
|
|||
import ( |
|||
"bytes" |
|||
"image" |
|||
"image/jpeg" |
|||
"io" |
|||
) |
|||
|
|||
// Options represent JPEG encoding options.
|
|||
type Options struct { |
|||
Quality int `json:"quality"` |
|||
} |
|||
|
|||
// DecodeJPEG decodes a JPEG file and return an image.
|
|||
func DecodeJPEG(r io.Reader) (image.Image, error) { |
|||
i, err := jpeg.Decode(r) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
return i, nil |
|||
} |
|||
|
|||
// EncodeJPEG encodes an image into JPEG and returns a buffer.
|
|||
func EncodeJPEG(i image.Image, o *Options) (buf bytes.Buffer, err error) { |
|||
err = jpeg.Encode(&buf, i, &jpeg.Options{Quality: o.Quality}) |
|||
return buf, err |
|||
} |
@ -0,0 +1,55 @@ |
|||
package localstore |
|||
|
|||
import ( |
|||
"github.com/vrischmann/userdir" |
|||
"io/ioutil" |
|||
"os" |
|||
"path" |
|||
) |
|||
|
|||
// LocalStore provides reading and writing application data to the user's
|
|||
// configuration directory.
|
|||
type LocalStore struct { |
|||
ConfDir string |
|||
} |
|||
|
|||
// NewLocalStore returns a localStore instance.
|
|||
func NewLocalStore() *LocalStore { |
|||
return &LocalStore{ConfDir: path.Join(userdir.GetConfigHome(), "Optimus")} |
|||
} |
|||
|
|||
// Load reads the given file in the user's configuration directory and returns
|
|||
// its contents.
|
|||
func (l *LocalStore) Load(filename string) ([]byte, error) { |
|||
p := path.Join(l.ConfDir, filename) |
|||
d, err := ioutil.ReadFile(p) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
return d, err |
|||
} |
|||
|
|||
// Store writes data to the user's configuration directory at the given
|
|||
// filename.
|
|||
func (l *LocalStore) Store(data []byte, filename string) error { |
|||
p := path.Join(l.ConfDir, filename) |
|||
if err := ensureDirExists(l.ConfDir); err != nil { |
|||
return err |
|||
} |
|||
if err := ioutil.WriteFile(p, data, 0777); err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// ensureDirExists checks for the existence of the directory at the given path,
|
|||
// which is created if it does not exist.
|
|||
func ensureDirExists(path string) error { |
|||
_, err := os.Stat(path) |
|||
if os.IsNotExist(err) { |
|||
if err = os.Mkdir(path, 0777); err != nil { |
|||
return err |
|||
} |
|||
} |
|||
return nil |
|||
} |
@ -0,0 +1,39 @@ |
|||
package png |
|||
|
|||
import ( |
|||
"bytes" |
|||
"github.com/foobaz/lossypng/lossypng" |
|||
"image" |
|||
"image/png" |
|||
"io" |
|||
) |
|||
|
|||
const qMax = 20 |
|||
|
|||
// Options represent PNG encoding options.
|
|||
type Options struct { |
|||
Quality int `json:"quality"` |
|||
} |
|||
|
|||
// DecodePNG decodes a PNG file and return an image.
|
|||
func DecodePNG(r io.Reader) (image.Image, error) { |
|||
i, err := png.Decode(r) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
return i, nil |
|||
} |
|||
|
|||
// EncodePNG encodes an image into PNG and returns a buffer.
|
|||
func EncodePNG(i image.Image, o *Options) (buf bytes.Buffer, err error) { |
|||
c := lossypng.Compress(i, 2, qualityFactor(o.Quality)) |
|||
err = png.Encode(&buf, c) |
|||
return buf, err |
|||
} |
|||
|
|||
// qualityFactor normalizes the PNG quality factor from a max of 20, where 0 is
|
|||
// no conversion.
|
|||
func qualityFactor(q int) int { |
|||
f := q / 100 |
|||
return qMax - (f * qMax) |
|||
} |
@ -0,0 +1,94 @@ |
|||
package stat |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"github.com/wailsapp/wails" |
|||
"optimus/backend/localstore" |
|||
) |
|||
|
|||
const filename = "stats.json" |
|||
|
|||
// Stat represents application statistics.
|
|||
type Stat struct { |
|||
ByteCount int64 `json:"byteCount"` |
|||
ImageCount int `json:"imageCount"` |
|||
TimeCount int64 `json:"timeCount"` |
|||
|
|||
Runtime *wails.Runtime |
|||
Logger *wails.CustomLogger |
|||
|
|||
localStore *localstore.LocalStore |
|||
} |
|||
|
|||
// NewStat returns a new Stat instance.
|
|||
func NewStat() *Stat { |
|||
s := &Stat{ |
|||
localStore: localstore.NewLocalStore(), |
|||
} |
|||
|
|||
d, _ := s.localStore.Load(filename) |
|||
_ = json.Unmarshal(d, &s) |
|||
return s |
|||
} |
|||
|
|||
// WailsInit performs setup when Wails is ready.
|
|||
func (s *Stat) WailsInit(runtime *wails.Runtime) error { |
|||
s.Runtime = runtime |
|||
s.Logger = s.Runtime.Log.New("Stat") |
|||
s.Logger.Info("Stat initialized...") |
|||
return nil |
|||
} |
|||
|
|||
// GetStats returns the application stats.
|
|||
func (s *Stat) GetStats() map[string]interface{} { |
|||
return map[string]interface{}{ |
|||
"byteCount": s.ByteCount, |
|||
"imageCount": s.ImageCount, |
|||
"timeCount": s.TimeCount, |
|||
} |
|||
} |
|||
|
|||
// SetByteCount adds and persists the given byte count to the app stats.
|
|||
func (s *Stat) SetByteCount(b int64) { |
|||
if b <= 0 { |
|||
return |
|||
} |
|||
s.ByteCount += b |
|||
if err := s.store(); err != nil { |
|||
s.Logger.Errorf("failed to store stats: %v", err) |
|||
} |
|||
} |
|||
|
|||
// SetImageCount adds and persists the given image count to the app stats.
|
|||
func (s *Stat) SetImageCount(i int) { |
|||
if i <= 0 { |
|||
return |
|||
} |
|||
s.ImageCount += i |
|||
if err := s.store(); err != nil { |
|||
s.Logger.Errorf("failed to store stats: %v", err) |
|||
} |
|||
} |
|||
|
|||
// SetTimeCount adds and persists the given time count to the app stats.
|
|||
func (s *Stat) SetTimeCount(t int64) { |
|||
if t < 0 { |
|||
return |
|||
} |
|||
s.TimeCount += t |
|||
if err := s.store(); err != nil { |
|||
s.Logger.Errorf("failed to store stats: %v", err) |
|||
} |
|||
} |
|||
|
|||
// store stores the app stats to the file system.
|
|||
func (s *Stat) store() error { |
|||
js, err := json.Marshal(s.GetStats()) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
if err = s.localStore.Store(js, filename); err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
} |
@ -0,0 +1,31 @@ |
|||
package webp |
|||
|
|||
import ( |
|||
"bytes" |
|||
"github.com/chai2010/webp" |
|||
"image" |
|||
"io" |
|||
) |
|||
|
|||
// Options represent WebP encoding options.
|
|||
type Options struct { |
|||
Lossless bool `json:"lossless"` |
|||
Quality int `json:"quality"` |
|||
} |
|||
|
|||
// DecodeWebp a webp file and return an image.
|
|||
func DecodeWebp(r io.Reader) (image.Image, error) { |
|||
i, err := webp.Decode(r) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
return i, nil |
|||
} |
|||
|
|||
// EncodeWebp encodes an image into webp and returns a buffer.
|
|||
func EncodeWebp(i image.Image, o *Options) (buf bytes.Buffer, err error) { |
|||
if err = webp.Encode(&buf, i, &webp.Options{Lossless: o.Lossless, Quality: float32(o.Quality)}); err != nil { |
|||
return buf, err |
|||
} |
|||
return buf, nil |
|||
} |
@ -1,6 +1,4 @@ |
|||
# go build -ldflags="-s -w -H=windowsgui" main.go |
|||
|
|||
cd frontend |
|||
npm install && npm run build |
|||
cd ../ |
|||
wails build |
|||
npm install && npm run build && cd ../ && wails build |
@ -0,0 +1,19 @@ |
|||
{ |
|||
"title": "Optimus installer", |
|||
"background-color": "#326DE6", |
|||
"icon-size": 80, |
|||
"contents": [ |
|||
{ |
|||
"x": 192, |
|||
"y": 344, |
|||
"type": "file", |
|||
"path": "./build/optimus.app" |
|||
}, |
|||
{ |
|||
"x": 448, |
|||
"y": 344, |
|||
"type": "link", |
|||
"path": "/Applications" |
|||
} |
|||
] |
|||
} |
@ -0,0 +1,4 @@ |
|||
{ |
|||
"semi": false, |
|||
"singleQuote": true |
|||
} |
@ -0,0 +1,35 @@ |
|||
# vue basic |
|||
|
|||
## Project setup |
|||
|
|||
``` |
|||
npm install |
|||
``` |
|||
|
|||
### Compiles and hot-reloads for development |
|||
|
|||
``` |
|||
npm run serve |
|||
``` |
|||
|
|||
### Compiles and minifies for production |
|||
|
|||
``` |
|||
npm run build |
|||
``` |
|||
|
|||
### Run your tests |
|||
|
|||
``` |
|||
npm run test |
|||
``` |
|||
|
|||
### Lints and fixes files |
|||
|
|||
``` |
|||
npm run lint |
|||
``` |
|||
|
|||
### Customize configuration |
|||
|
|||
See [Configuration Reference](https://cli.vuejs.org/config/). |
@ -0,0 +1,3 @@ |
|||
module.exports = { |
|||
presets: [['@vue/app', { useBuiltIns: 'entry' }]] |
|||
} |
@ -0,0 +1,55 @@ |
|||
{ |
|||
"name": "Optimus", |
|||
"author": { |
|||
"name": "Christopher Murphy", |
|||
"email": "flyweight@protonmail.com" |
|||
}, |
|||
"private": true, |
|||
"version": "0.5.0-beta", |
|||
"scripts": { |
|||
"serve": "vue-cli-service serve", |
|||
"build": "vue-cli-service build", |
|||
"lint": "vue-cli-service lint" |
|||
}, |
|||
"dependencies": { |
|||
"@wailsapp/runtime": "^1.1.1", |
|||
"autoprefixer": "^9.8.6", |
|||
"core-js": "^3.6.4", |
|||
"regenerator-runtime": "^0.13.7", |
|||
"tailwindcss": "^1.9.1", |
|||
"v-tooltip": "^2.0.3", |
|||
"vue": "^2.6.12", |
|||
"vue-slider-component": "^3.2.6", |
|||
"vuex": "^3.5.1" |
|||
}, |
|||
"devDependencies": { |
|||
"@vue/cli-plugin-babel": "^4.5.7", |
|||
"@vue/cli-plugin-eslint": "^4.5.7", |
|||
"@vue/cli-service": "^4.5.7", |
|||
"babel-eslint": "^10.1.0", |
|||
"eslint": "^7.11.0", |
|||
"eslint-plugin-vue": "^7.0.1", |
|||
"eventsource-polyfill": "^0.9.6", |
|||
"vue-template-compiler": "^2.6.12", |
|||
"webpack-hot-middleware": "^2.25.0" |
|||
}, |
|||
"eslintConfig": { |
|||
"root": true, |
|||
"env": { |
|||
"node": true |
|||
}, |
|||
"extends": [ |
|||
"plugin:vue/essential", |
|||
"eslint:recommended" |
|||
], |
|||
"rules": {}, |
|||
"parserOptions": { |
|||
"parser": "babel-eslint" |
|||
} |
|||
}, |
|||
"browserslist": [ |
|||
"> 1%", |
|||
"last 2 versions", |
|||
"not ie <= 8" |
|||
] |
|||
} |
@ -0,0 +1 @@ |
|||
5426204eb3ec3fb253ed2169cd3fd066 |
@ -0,0 +1,6 @@ |
|||
module.exports = { |
|||
plugins: [ |
|||
require('tailwindcss')('tailwind.config.js'), |
|||
require('autoprefixer')() |
|||
] |
|||
} |
@ -0,0 +1,57 @@ |
|||
<template> |
|||
<div id="app" class="bg-gray-900 flex relative h-full"> |
|||
<Sidebar :active="currentView" v-on:select-view="handleViewSelect" /> |
|||
<keep-alive> |
|||
<component :is="currentView" v-on:close-view="handleViewClose" /> |
|||
</keep-alive> |
|||
<Notification /> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import About from './components/About.vue' |
|||
import Editor from './components/Editor.vue' |
|||
import Notification from './components/Notification.vue' |
|||
import Settings from './components/Settings.vue' |
|||
import Sidebar from './components/Sidebar.vue' |
|||
import Stats from './components/Stats.vue' |
|||
import './assets/css/main.css' |
|||
|
|||
export default { |
|||
name: 'app', |
|||
|
|||
components: { |
|||
About, |
|||
Editor, |
|||
Notification, |
|||
Settings, |
|||
Sidebar, |
|||
Stats |
|||
}, |
|||
|
|||
data() { |
|||
return { |
|||
currentView: 'Editor' |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
handleViewClose() { |
|||
this.currentView = 'Editor' |
|||
}, |
|||
|
|||
handleViewSelect(e) { |
|||
if (this.currentView === e) { |
|||
this.currentView = 'Editor' |
|||
} else { |
|||
this.currentView = e |
|||
} |
|||
} |
|||
}, |
|||
|
|||
mounted() { |
|||
this.$store.dispatch('getConfig') |
|||
this.$store.dispatch('getStats') |
|||
} |
|||
} |
|||
</script> |
@ -0,0 +1,60 @@ |
|||
.fade-enter-active, .fade-leave-active { |
|||
transition: opacity 600ms cubic-bezier(.07, .95, 0, 1); |
|||
} |
|||
|
|||
.fade-enter, .fade-leave-to { |
|||
opacity: 0; |
|||
} |
|||
|
|||
.fade-fast-enter-active, .fade-fast-leave-active { |
|||
transition: opacity 200ms ease-in-out; |
|||
} |
|||
|
|||
.fade-fast-enter, .fade-fast-leave-to { |
|||
opacity: 0; |
|||
} |
|||
|
|||
.fade-list-enter-active, .fade-list-leave-active { |
|||
transition: all 1.2s ease-in-out; |
|||
} |
|||
|
|||
.fade-list-enter, .fade-list-leave-to { |
|||
opacity: 0; |
|||
transform: translateX(3rem); |
|||
} |
|||
|
|||
.fade-list-move { |
|||
transition: all 1s ease-in-out; |
|||
} |
|||
|
|||
.ta { |
|||
transition: all .3s cubic-bezier(.07, .95, 0, 1); |
|||
} |
|||
|
|||
.ta-slow { |
|||
transition: all 1s cubic-bezier(.07, .95, 0, 1); |
|||
} |
|||
|
|||
.ta-color { |
|||
transition: color .3s cubic-bezier(.07, .95, 0, 1); |
|||
} |
|||
|
|||
.ta-color-slow { |
|||
transition: color 1s cubic-bezier(.07, .95, 0, 1); |
|||
} |
|||
|
|||
.anime-txt-success { |
|||
animation: txt-success 600ms ease-in-out forwards; |
|||
} |
|||
|
|||
@keyframes txt-success { |
|||
0% { |
|||
color: #b3b3b3; |
|||
} |
|||
50% { |
|||
color: #27ffa7; |
|||
} |
|||
100% { |
|||
color: #b3b3b3; |
|||
} |
|||
} |
@ -0,0 +1,9 @@ |
|||
.btn { |
|||
@apply border-2 flex font-medium items-center justify-center px-8 py-2 rounded-full; |
|||
min-width: 136px; |
|||
min-height: 40px; |
|||
} |
|||
|
|||
.btn--disabled { |
|||
@apply bg-gray-700 border-gray-700 cursor-default text-gray-400; |
|||
} |
@ -0,0 +1,81 @@ |
|||
/* vue-select */ |
|||
|
|||
.dropdown-toggle { |
|||
background-color: #18181f; |
|||
background-image: none; |
|||
border-radius: 0.375rem; |
|||
color: #b3b3b3; |
|||
font-weight: 400; |
|||
margin: 0; |
|||
transition: color .3s cubic-bezier(.07, .95, 0, 1); |
|||
} |
|||
|
|||
.dropdown-toggle:hover { |
|||
background-color: #18181f; |
|||
color: #27ffa7; |
|||
} |
|||
|
|||
.dropdown-menu { |
|||
background-color: #18181f; |
|||
border: 0; |
|||
border-top: 1px solid #3a3a42; |
|||
border-top-left-radius: 0; |
|||
border-top-right-radius: 0; |
|||
} |
|||
|
|||
.dropdown-menu > li > a { |
|||
color: #b3b3b3; |
|||
font-weight: 500; |
|||
transition: all .3s cubic-bezier(.07, .95, 0, 1); |
|||
} |
|||
|
|||
.dropdown-menu > li > a:hover { |
|||
background-color: #27ffa7; |
|||
color: #18181f; |
|||
} |
|||
|
|||
/* vue slider*/ |
|||
|
|||
.vue-slider-rail { |
|||
background-color: #18181f; |
|||
} |
|||
|
|||
.vue-slider:hover .vue-slider-rail { |
|||
background-color: #18181f; |
|||
} |
|||
|
|||
.vue-slider:hover .vue-slider-dot-handle { |
|||
border-color: transparent; |
|||
opacity: 1; |
|||
} |
|||
|
|||
.vue-slider:hover .vue-slider-dot-handle:hover { |
|||
border-color: transparent; |
|||
} |
|||
|
|||
.vue-slider-dot-handle { |
|||
background-color: #cbccd2; |
|||
border-color: transparent; |
|||
opacity: 0; |
|||
transition: all .6s cubic-bezier(.07, .95, 0, 1); |
|||
} |
|||
|
|||
.slider-blue .vue-slider-dot-handle:hover, |
|||
.slider-pink .vue-slider-dot-handle:hover, |
|||
.slider-yellow .vue-slider-dot-handle:hover { |
|||
border-color: transparent; |
|||
} |
|||
|
|||
|
|||
.vue-slider-dot-handle:hover, |
|||
.vue-slider-process, |
|||
.vue-slider:hover .vue-slider-process, |
|||
.vue-slider-dot-handle { |
|||
background-color: #27ffa7; |
|||
} |
|||
|
|||
.vue-slider-dot-tooltip-inner { |
|||
background-color: #18181f; |
|||
border-color: #18181f; |
|||
color: #f4f5f9; |
|||
} |
@ -0,0 +1,37 @@ |
|||
@import url('animations.css'); |
|||
@import url('buttons.css'); |
|||
@import url('input.css'); |
|||
@import url('tooltip.css'); |
|||
|
|||
@tailwind base; |
|||
@tailwind components; |
|||
@tailwind utilities; |
|||
|
|||
|
|||
#app { |
|||
-webkit-font-smoothing: antialiased; |
|||
-moz-osx-font-smoothing: grayscale; |
|||
color: #b3b3b3; |
|||
} |
|||
|
|||
* { |
|||
user-select: none; |
|||
} |
|||
|
|||
html { |
|||
background-color: #18181f; |
|||
background-size: 20px 20px; |
|||
overflow: hidden; |
|||
height: 100%; |
|||
} |
|||
|
|||
body { |
|||
scrollbar-base-color: #18181f; |
|||
scrollbar-face-color: #212128; |
|||
scrollbar-3dlight-color: #18181f; |
|||
scrollbar-highlight-color: #18181f; |
|||
scrollbar-track-color: #18181f; |
|||
scrollbar-arrow-color: #18181f; |
|||
scrollbar-shadow-color: #212128; |
|||
height: 100%; |
|||
} |
@ -0,0 +1,11 @@ |
|||
.tooltip { |
|||
background-color: #18181f; |
|||
/*border: 2px solid #3a3a42;*/ |
|||
border-radius: 6px; |
|||
color: #f4f5f9; |
|||
display: block; |
|||
font-size: 14px; |
|||
max-width: 280px; |
|||
padding: 0.5rem 1rem; |
|||
z-index: 100; |
|||
} |
After Width: | Height: | Size: 14 KiB |
@ -0,0 +1,78 @@ |
|||
<template> |
|||
<section class="bg-gray-800 overflow-y-auto p-10 w-full"> |
|||
<header class="flex items-center justify-between w-full"> |
|||
<h1 class="font-medium text-2xl text-purple-400">About</h1> |
|||
<BtnClose @click.native="closeView" color="purple" /> |
|||
</header> |
|||
<div class="flex flex-wrap items-center justify-center pt-16 w-full"> |
|||
<div class="flex items-center justify-center logo w-full h-64"></div> |
|||
<!-- info --> |
|||
<div class="text-center text-gray-100 w-full"> |
|||
<p class="mb-1">{{ pkg.name }} {{ pkg.version }}</p> |
|||
<p class="mb-1"> |
|||
© 2020 |
|||
<span |
|||
class="cursor-pointer hover:text-purple-400 ta-color-slow" |
|||
@click="openLink('https://christophermurphy.dev')" |
|||
>{{ pkg.author.name }}</span |
|||
> |
|||
</p> |
|||
<p class="mb-1"> |
|||
<span |
|||
class="cursor-pointer hover:text-purple-400 ta-color-slow" |
|||
@click=" |
|||
openLink('https://github.com/Splode/optimus/blob/main/LICENSE') |
|||
" |
|||
>License</span |
|||
> |
|||
and |
|||
<span |
|||
class="cursor-pointer hover:text-purple-400 ta-color-slow" |
|||
@click="openLink('https://github.com/splode/optimus')" |
|||
>Documentation</span |
|||
> |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</template> |
|||
|
|||
<script> |
|||
import BtnClose from './BtnClose' |
|||
import pkg from './../../package.json' |
|||
|
|||
export default { |
|||
name: 'About', |
|||
|
|||
components: { BtnClose }, |
|||
|
|||
data() { |
|||
return { |
|||
pkg |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
closeView() { |
|||
this.$emit('close-view') |
|||
}, |
|||
|
|||
openLink(u) { |
|||
window.wails.Browser.OpenURL(u) |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.logo { |
|||
background-image: url('./../assets/images/optimus-logo--640x360.png'); |
|||
background-position: center; |
|||
background-repeat: no-repeat; |
|||
background-size: contain; |
|||
} |
|||
|
|||
span { |
|||
box-shadow: 0 1px #d690ff; |
|||
} |
|||
</style> |
@ -0,0 +1,57 @@ |
|||
<template> |
|||
<button |
|||
class="border-2 border-gray-800 cursor-pointer flex focus:outline-none hover:border-gray-400 hover:bg-gray-900 items-center justify-center p-2 ta-slow rounded-full w-10 h-10" |
|||
> |
|||
<svg |
|||
version="1.1" |
|||
id="x" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
xmlns:xlink="http://www.w3.org/1999/xlink" |
|||
x="0px" |
|||
y="0px" |
|||
viewBox="0 0 11.9 11.9" |
|||
style="enable-background:new 0 0 11.9 11.9;" |
|||
width="14" |
|||
height="14" |
|||
:class="color" |
|||
xml:space="preserve" |
|||
> |
|||
<path |
|||
fill="#b3b3b3" |
|||
d="M10.4,0L6,4.5L1.5,0L0,1.5L4.5,6L0,10.4l1.5,1.5L6,7.5l4.5,4.5l1.5-1.5L7.5,6l4.5-4.5L10.4,0z" |
|||
/> |
|||
</svg> |
|||
</button> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: 'BtnClose', |
|||
|
|||
props: { |
|||
color: { |
|||
type: String, |
|||
required: false, |
|||
default: 'green' |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
button > svg > path { |
|||
transition: fill 0.6s cubic-bezier(0.07, 0.95, 0, 1); |
|||
} |
|||
|
|||
button:hover > svg.blue > path { |
|||
fill: #27d1ff; |
|||
} |
|||
|
|||
button:hover > svg.green > path { |
|||
fill: #27ffa7; |
|||
} |
|||
|
|||
button:hover > svg.purple > path { |
|||
fill: #d690ff; |
|||
} |
|||
</style> |
@ -0,0 +1,195 @@ |
|||
<template> |
|||
<div class="btn-group"> |
|||
<li |
|||
@click="toggleMenu()" |
|||
class="dropdown-toggle py-2" |
|||
v-if="selectedOption.name !== undefined" |
|||
:class="showMenu ? 'rounded-bl-none rounded-br-none' : ''" |
|||
> |
|||
{{ selectedOption.name }} |
|||
<span class="caret"></span> |
|||
</li> |
|||
|
|||
<li |
|||
@click="toggleMenu()" |
|||
class="dropdown-toggle" |
|||
v-if="selectedOption.name === undefined" |
|||
> |
|||
{{ placeholderText }} |
|||
<span class="caret"></span> |
|||
</li> |
|||
|
|||
<ul class="dropdown-menu" v-if="showMenu"> |
|||
<li v-for="(option, idx) in options" :key="idx"> |
|||
<a href="javascript:void(0)" @click="updateOption(option)"> |
|||
{{ option.name }} |
|||
</a> |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: 'Dropdown', |
|||
|
|||
data() { |
|||
return { |
|||
selectedOption: { |
|||
name: '' |
|||
}, |
|||
showMenu: false, |
|||
placeholderText: 'Please select an item' |
|||
} |
|||
}, |
|||
props: { |
|||
options: { |
|||
type: [Array, Object] |
|||
}, |
|||
selected: {}, |
|||
placeholder: [String], |
|||
closeOnOutsideClick: { |
|||
type: [Boolean], |
|||
default: true |
|||
} |
|||
}, |
|||
|
|||
watch: { |
|||
selected(o) { |
|||
this.updateOption(o) |
|||
} |
|||
}, |
|||
|
|||
mounted() { |
|||
this.selectedOption = this.selected |
|||
if (this.placeholder) { |
|||
this.placeholderText = this.placeholder |
|||
} |
|||
if (this.closeOnOutsideClick) { |
|||
document.addEventListener('click', this.clickHandler) |
|||
} |
|||
}, |
|||
beforeDestroy() { |
|||
document.removeEventListener('click', this.clickHandler) |
|||
}, |
|||
methods: { |
|||
updateOption(option) { |
|||
this.selectedOption = option |
|||
this.showMenu = false |
|||
this.$emit('update-option', this.selectedOption) |
|||
}, |
|||
toggleMenu() { |
|||
this.showMenu = !this.showMenu |
|||
}, |
|||
clickHandler(event) { |
|||
const { target } = event |
|||
const { $el } = this |
|||
if (!$el.contains(target)) { |
|||
this.showMenu = false |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style> |
|||
.btn-group { |
|||
min-width: 160px; |
|||
height: 40px; |
|||
position: relative; |
|||
margin: 10px 1px; |
|||
display: inline-block; |
|||
vertical-align: middle; |
|||
} |
|||
|
|||
.btn-group a:hover { |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.dropdown-toggle { |
|||
color: #636b6f; |
|||
min-width: 160px; |
|||
padding: 10px 20px 10px 10px; |
|||
text-transform: none; |
|||
font-weight: 300; |
|||
margin-bottom: 7px; |
|||
border: 0; |
|||
background-image: linear-gradient(#009688, #009688), |
|||
linear-gradient(#d2d2d2, #d2d2d2); |
|||
background-size: 0 2px, 100% 1px; |
|||
background-repeat: no-repeat; |
|||
background-position: center bottom, center calc(100% - 1px); |
|||
background-color: transparent; |
|||
transition: background 0s ease-out; |
|||
float: none; |
|||
box-shadow: none; |
|||
border-radius: 0; |
|||
white-space: nowrap; |
|||
text-overflow: ellipsis; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.dropdown-toggle:hover { |
|||
background: #e1e1e1; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.dropdown-menu { |
|||
position: absolute; |
|||
top: 100%; |
|||
left: 0; |
|||
z-index: 1000; |
|||
float: left; |
|||
min-width: 160px; |
|||
/*padding: 5px 0;*/ |
|||
/*margin: 2px 0 0;*/ |
|||
list-style: none; |
|||
font-size: 14px; |
|||
text-align: left; |
|||
background-color: #fff; |
|||
border: 1px solid #ccc; |
|||
border-radius: 4px; |
|||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); |
|||
background-clip: padding-box; |
|||
} |
|||
|
|||
.dropdown-menu > li > a { |
|||
padding: 10px 30px; |
|||
display: block; |
|||
clear: both; |
|||
font-weight: normal; |
|||
line-height: 1.6; |
|||
color: #333333; |
|||
white-space: nowrap; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.dropdown-menu > li > a:hover { |
|||
background: #efefef; |
|||
color: #409fcb; |
|||
} |
|||
|
|||
.dropdown-menu > li { |
|||
overflow: hidden; |
|||
width: 100%; |
|||
position: relative; |
|||
margin: 0; |
|||
} |
|||
|
|||
.caret { |
|||
width: 0; |
|||
position: absolute; |
|||
top: 19px; |
|||
height: 0; |
|||
margin-left: -24px; |
|||
vertical-align: middle; |
|||
border-top: 4px dashed; |
|||
border-right: 4px solid transparent; |
|||
border-left: 4px solid transparent; |
|||
right: 10px; |
|||
} |
|||
|
|||
li { |
|||
list-style: none; |
|||
} |
|||
</style> |
@ -0,0 +1,746 @@ |
|||
<template> |
|||
<section class="p-10 w-full"> |
|||
<header class="border-b-2 border-gray-800 flex flex-wrap" ref="header"> |
|||
<div class="w-full md:w-1/2 lg:w-5/12"> |
|||
<div |
|||
class="bg-gray-800 border-2 border-dashed cursor-pointer drop-zone flex flex-col items-center justify-center py-6 md:py-10 ta-slow rounded-sm h-full" |
|||
:class="isDragging ? 'border-green' : 'border-gray-400'" |
|||
ref="dropZone" |
|||
> |
|||
<svg |
|||
version="1.1" |
|||
id="dropZone-plus" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
xmlns:xlink="http://www.w3.org/1999/xlink" |
|||
x="0px" |
|||
y="0px" |
|||
viewBox="0 0 48 48" |
|||
enable-background="new 0 0 48 48" |
|||
width="48px" |
|||
height="48px" |
|||
xml:space="preserve" |
|||
> |
|||
<path |
|||
fill="#808080" |
|||
d="M47,20.6H28.4c-0.6,0-1-0.4-1-1V1c0-0.6-0.4-1-1-1h-4.9c-0.6,0-1,0.4-1,1v18.6c0,0.6-0.4,1-1,1H1 |
|||
c-0.6,0-1,0.4-1,1v4.9c0,0.6,0.4,1,1,1h18.6c0.6,0,1,0.4,1,1V47c0,0.6,0.4,1,1,1h4.9c0.6,0,1-0.4,1-1V28.4c0-0.6,0.4-1,1-1H47 |
|||
c0.6,0,1-0.4,1-1v-4.9C48,21,47.6,20.6,47,20.6z" |
|||
/> |
|||
</svg> |
|||
<p class="mt-6 text-gray-200">Drag and drop or select images</p> |
|||
</div> |
|||
</div> |
|||
<div class="lg:w-7/12 md:pl-6 md:w-1/2 my-3 w-full"> |
|||
<transition name="fade" mode="out-in"> |
|||
<div |
|||
v-if="!session.count" |
|||
key="intro" |
|||
class="flex h-full items-center justify-center" |
|||
> |
|||
<h2 class="leading-none text-4xl text-center text-yellow"> |
|||
Add image files<br />to get started |
|||
</h2> |
|||
</div> |
|||
<div v-else key="stats" class="flex flex-wrap items-end h-full"> |
|||
<div class="px-3 w-full lg:w-5/12"> |
|||
<div v-if="session.hasSavings"> |
|||
<h2 |
|||
class="font-bold leading-none text-4xl md:text-5xl text-green tracking-tight" |
|||
> |
|||
{{ session.savings }} |
|||
</h2> |
|||
<p class="font-medium text-gray-300 tracking-wider uppercase"> |
|||
Saved |
|||
</p> |
|||
</div> |
|||
<div v-else> |
|||
<h2 |
|||
class="font-bold leading-none text-5xl text-pink tracking-tight uppercase" |
|||
> |
|||
No Savings |
|||
</h2> |
|||
<p class="font-medium text-gray-300 tracking-wider uppercase"> |
|||
Try adjusting options |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<div class="flex lg:flex-col justify-between px-3 w-full lg:w-3/12"> |
|||
<div class="w-1/2 lg:w-full"> |
|||
<p class="font-bold text-xl lg:text-2xl text-blue"> |
|||
{{ session.count }} |
|||
</p> |
|||
<p |
|||
class="font-medium text-gray-300 text-sm tracking-wider uppercase" |
|||
> |
|||
{{ session.count > 1 ? 'Images' : 'Image' }} |
|||
</p> |
|||
</div> |
|||
<div class="w-1/2 lg:w-full"> |
|||
<p class="font-bold text-xl lg:text-2xl text-yellow"> |
|||
{{ session.time[0] }} |
|||
</p> |
|||
<p |
|||
class="font-medium text-gray-300 text-sm tracking-wider uppercase" |
|||
> |
|||
{{ session.time[1] }} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
<div class="flex lg:flex-col justify-between px-3 w-full lg:w-4/12"> |
|||
<div class="w-1/2 lg:w-full"> |
|||
<p class="font-bold text-xl lg:text-2xl text-pink"> |
|||
{{ totalStats.byteCount }} |
|||
</p> |
|||
<p |
|||
class="font-medium text-gray-300 text-sm tracking-wider uppercase" |
|||
> |
|||
All time Savings |
|||
</p> |
|||
</div> |
|||
<div class="w-1/2 lg:w-full"> |
|||
<p class="font-bold text-xl lg:text-2xl text-purple"> |
|||
{{ totalStats.imageCount }} |
|||
</p> |
|||
<p |
|||
class="font-medium text-gray-300 text-sm tracking-wider uppercase" |
|||
> |
|||
All time Images |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</transition> |
|||
</div> |
|||
<footer class="w-full"> |
|||
<section |
|||
class="flex justify-between lg:w-5/12 md:w-1/2 py-3 md:py-6 w-full" |
|||
> |
|||
<button |
|||
class="btn focus:outline-none ta-slow" |
|||
:class=" |
|||
canConvert |
|||
? 'border-green hover:bg-green hover:text-gray-900 text-gray-200' |
|||
: 'btn--disabled' |
|||
" |
|||
@click="convert" |
|||
:disabled="!canConvert" |
|||
> |
|||
{{ optBtnTxt }} |
|||
</button> |
|||
<button |
|||
class="btn focus:outline-none ta-slow" |
|||
:class=" |
|||
canClear |
|||
? 'border-gray-400 hover:bg-gray-800 hover:text-green' |
|||
: 'btn--disabled' |
|||
" |
|||
@click="clear" |
|||
:disabled="!canClear" |
|||
> |
|||
Clear |
|||
</button> |
|||
</section> |
|||
</footer> |
|||
</header> |
|||
<input |
|||
type="file" |
|||
accept="image/jpeg, image/jpg, image/png, image/webp" |
|||
multiple |
|||
class="hidden" |
|||
@input="handleFileInput" |
|||
ref="fileInput" |
|||
/> |
|||
<!-- file table --> |
|||
<transition name="fade" mode="out-in"> |
|||
<div |
|||
v-if="files.length > 0" |
|||
class="table-wrapper" |
|||
:style="{ height: `calc(100vh - ${headerHeight + 80}px)` }" |
|||
> |
|||
<table class="table-auto w-full text-left whitespace-no-wrap"> |
|||
<thead> |
|||
<tr> |
|||
<th |
|||
class="font-medium pl-3 pt-6 text-gray-400 text-left text-sm tracking-wider uppercase" |
|||
> |
|||
File |
|||
</th> |
|||
<th |
|||
class="font-medium pl-3 pt-6 text-gray-400 text-left text-sm tracking-wider uppercase" |
|||
> |
|||
Size |
|||
</th> |
|||
<th |
|||
class="font-medium pl-3 pt-6 text-gray-400 text-left text-sm tracking-wider uppercase" |
|||
> |
|||
Compressed |
|||
</th> |
|||
<th |
|||
class="font-medium pl-3 pt-6 text-gray-400 text-left text-sm tracking-wider uppercase" |
|||
> |
|||
Ratio |
|||
</th> |
|||
<!-- <th--> |
|||
<!-- class="font-medium pl-3 pt-6 text-gray-400 text-left text-sm tracking-wider uppercase"--> |
|||
<!-- >--> |
|||
<!-- Result--> |
|||
<!-- </th>--> |
|||
<th |
|||
class="font-medium pl-3 pt-6 text-center text-gray-400 text-left text-sm tracking-wider uppercase" |
|||
> |
|||
Status |
|||
</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="(file, i) in files" :key="`${i}-${file.name}`"> |
|||
<td> |
|||
<p |
|||
class="cell-l ta" |
|||
:class="{ |
|||
'text-gray-400': !file.isProcessed, |
|||
'text-gray-200': file.isProcessed, |
|||
'anime-txt-success': file.isConverted |
|||
}" |
|||
> |
|||
{{ file.filename }} |
|||
</p> |
|||
</td> |
|||
<td> |
|||
<p |
|||
class="ta" |
|||
:class="{ |
|||
'text-gray-400': !file.isProcessed, |
|||
'text-gray-200': file.isProcessed, |
|||
'anime-txt-success': file.isConverted |
|||
}" |
|||
> |
|||
{{ getPrettySize(file.size) }} |
|||
</p> |
|||
</td> |
|||
<td> |
|||
<p :class="{ 'anime-txt-success': file.isConverted }"> |
|||
{{ getPrettySize(file.convertedSize) }} |
|||
</p> |
|||
</td> |
|||
<td> |
|||
<p :class="{ 'anime-txt-success': file.isConverted }"> |
|||
{{ getSavings(file) }} |
|||
</p> |
|||
</td> |
|||
<!-- <td @click="openFile(file)"><p>{{ file.convertedPath }}</p>--> |
|||
<!-- </td>--> |
|||
<td> |
|||
<p |
|||
v-if="file.isConverted" |
|||
class="cell-r flex items-center justify-center" |
|||
> |
|||
<svg |
|||
version="1.1" |
|||
:id="`${i}-check`" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
xmlns:xlink="http://www.w3.org/1999/xlink" |
|||
x="0px" |
|||
y="0px" |
|||
viewBox="0 0 20 20" |
|||
enable-background="new 0 0 20 20" |
|||
width="20px" |
|||
height="20px" |
|||
xml:space="preserve" |
|||
> |
|||
<path |
|||
fill="#27ffa7" |
|||
d="M10,0C4.5,0,0,4.5,0,10s4.5,10,10,10s10-4.5,10-10S15.5,0,10,0z M8,14.4l-3.7-3.7l1.4-1.4L8,11.6l5.3-5.3 |
|||
l1.4,1.4L8,14.4z" |
|||
/> |
|||
</svg> |
|||
</p> |
|||
<p v-else class="cell-r"></p> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</transition> |
|||
</section> |
|||
</template> |
|||
|
|||
<script> |
|||
import { fExt, fName, fSize } from '@/lib/file' |
|||
import { EventBus } from '@/lib/event-bus' |
|||
import Wails from '@wailsapp/runtime' |
|||
import { prettyTime } from '@/lib/time' |
|||
|
|||
export default { |
|||
name: 'Editor', |
|||
|
|||
data() { |
|||
return { |
|||
files: [], |
|||
headerHeight: 0, |
|||
isConverting: false, |
|||
isDragging: false |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
/** |
|||
* canClear returns true if the file list can be cleared. |
|||
* @returns {boolean} |
|||
*/ |
|||
canClear() { |
|||
if (this.files.length === 0) return false |
|||
return !this.filesPending |
|||
}, |
|||
|
|||
/** |
|||
* canConvert returns true if the file list satisfies conditions for |
|||
* conversion. |
|||
* @returns {boolean} |
|||
*/ |
|||
canConvert() { |
|||
if (this.files.length === 0 || this.isConverting) return false |
|||
return this.filesConverted && !this.filesPending |
|||
}, |
|||
|
|||
/** |
|||
* filesConverted returns true if all files have been converted. |
|||
* @returns {boolean} |
|||
*/ |
|||
filesConverted() { |
|||
if (this.files.length === 0) return false |
|||
return this.files.some(f => !f.isConverted) |
|||
}, |
|||
|
|||
/** |
|||
* filesPending returns true if there are files not processed. |
|||
* @returns {boolean} |
|||
*/ |
|||
filesPending() { |
|||
if (this.files.length === 0) return false |
|||
return this.files.some(f => !f.isProcessed) |
|||
}, |
|||
|
|||
/** |
|||
* optBtnTxt returns the text string for the Optimize button per the |
|||
* current app state. |
|||
* @returns {string} |
|||
*/ |
|||
optBtnTxt() { |
|||
const d = 'Optimize' |
|||
if (this.files.length === 0) return d |
|||
if (this.filesPending) return 'Processing...' |
|||
return d |
|||
}, |
|||
|
|||
/** |
|||
* session returns the current session stats. |
|||
* @returns {object} |
|||
*/ |
|||
session() { |
|||
return this.$store.getters.session |
|||
}, |
|||
|
|||
/** |
|||
* totalStats returns the all-time stats. |
|||
*/ |
|||
totalStats() { |
|||
return this.$store.getters.stats |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
/** |
|||
* clear removes the files from the file list and the FileManager. |
|||
*/ |
|||
clear() { |
|||
this.files = [] |
|||
this.$refs['fileInput'].value = null |
|||
window.backend.FileManager.Clear() |
|||
.then(res => { |
|||
console.log(res) |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* convert calls the Convert method on the FileManager. |
|||
*/ |
|||
convert() { |
|||
this.isConverting = true |
|||
window.backend.FileManager.Convert() |
|||
.then(res => { |
|||
console.log(res) |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* createFileId creates a file ID based on its name and size. |
|||
* @param {string} name - The filename without extension. |
|||
* @param {number} size - The file size in bytes. |
|||
* @returns {string} - The file ID. |
|||
*/ |
|||
createFileId(name, size) { |
|||
return name + size.toString() |
|||
}, |
|||
|
|||
/** |
|||
* getFileById returns the file from the file list with the given |
|||
* ID. |
|||
* @param {string} id - The file ID. |
|||
* @returns {object} - The matched file. |
|||
*/ |
|||
getFileById(id) { |
|||
if (this.files.length === 0) return |
|||
return this.files.find(f => { |
|||
return f.id === id |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* getPrettySize returns a pretty string of file size given bytes. |
|||
* @param {number} size - Size in bytes. |
|||
* @returns {string} - The pretty size. |
|||
*/ |
|||
getPrettySize(size) { |
|||
if (size === 0) return '' |
|||
return fSize(size) |
|||
}, |
|||
|
|||
/** |
|||
* getSavings returns the percentage difference between a file's |
|||
* original and converted sizes as a pretty string. |
|||
* @param {object} file - A file in the file list. |
|||
* @returns {string} - The file savings as string. |
|||
*/ |
|||
getSavings(file) { |
|||
if (file.convertedSize === 0) return '' |
|||
const p = Math.floor(100 - (file.convertedSize / file.size) * 100) |
|||
return `${p.toString()}%` |
|||
}, |
|||
|
|||
/** |
|||
* getFileType attempts to determine the file image type based on the |
|||
* given file type and extension. This method exists due to IE's lack of |
|||
* support for WebP. |
|||
* @param {string} type - The file type. |
|||
* @param {string} ext - The file extension. |
|||
* @returns {string} |
|||
*/ |
|||
getFileType(type, ext) { |
|||
if (this.isValidType(type)) return type |
|||
if (this.isValidExt(ext)) return `image/${ext}` |
|||
return '' |
|||
}, |
|||
|
|||
/** |
|||
* handleFileInput handles the file submission via form input. |
|||
* @param {Event} e |
|||
*/ |
|||
handleFileInput(e) { |
|||
const f = e.target.files |
|||
this.processFileInput(f) |
|||
}, |
|||
|
|||
/** |
|||
* hasFile returns true if the file is in the file list. |
|||
* @param {string} id - The file ID. |
|||
* @returns {boolean} |
|||
*/ |
|||
hasFile(id) { |
|||
if (this.files.length === 0) return false |
|||
let e = false |
|||
this.files.forEach(f => { |
|||
if (f.id === id) { |
|||
e = true |
|||
} |
|||
}) |
|||
return e |
|||
}, |
|||
|
|||
/** |
|||
* handleWinResize calculates the height of the table based on the height |
|||
* of the header. |
|||
*/ |
|||
handleWinResize() { |
|||
const hh = this.$refs['header'].clientHeight |
|||
if (hh === this.headerHeight) return |
|||
this.headerHeight = hh |
|||
}, |
|||
|
|||
/** |
|||
* isValidExt returns true if the given file extension is of an accepted |
|||
* set of extensions. |
|||
* @param {string} ext - A file extension |
|||
* @returns {boolean} |
|||
*/ |
|||
isValidExt(ext) { |
|||
const v = ['jpg', 'jpeg', 'png', 'webp'] |
|||
return v.indexOf(ext) >= 0 |
|||
}, |
|||
|
|||
/** |
|||
* isValidType returns true if the given type is one of an accepted |
|||
* set of mime types. |
|||
* @param {string} type - A file's mime type. |
|||
* @return {boolean} |
|||
*/ |
|||
isValidType(type) { |
|||
const v = [ |
|||
'image/jpg', |
|||
'image/jpeg', |
|||
'image/.jpg', |
|||
'image/png', |
|||
'image/webp' |
|||
] |
|||
return v.indexOf(type) >= 0 |
|||
}, |
|||
|
|||
/** |
|||
* openFile opens the file at the given file path. |
|||
*/ |
|||
openFile(file) { |
|||
// TODO: can this be called directly from Wails? |
|||
window.backend.FileManager.OpenFile(file.convertedPath) |
|||
.then(res => { |
|||
console.log(res) |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* processFileInput processes a list of submitted files. |
|||
* @param {FileList} f - A FileList array of files. |
|||
*/ |
|||
processFileInput(f) { |
|||
f.forEach(f => { |
|||
const name = fName(f.name) |
|||
const ext = fExt(f.name) |
|||
const type = this.getFileType(f.type, ext) |
|||
const size = f.size |
|||
const id = this.createFileId(name, size) |
|||
// reject if wrong mime or already exists |
|||
if (!type) { |
|||
EventBus.$emit('notify', { |
|||
msg: `File type not supported. Valid file types include JPG, PNG, and WebP.`, |
|||
type: 'warn' |
|||
}) |
|||
return |
|||
} |
|||
if (this.hasFile(id)) { |
|||
EventBus.$emit('notify', { |
|||
msg: `Image file has already been added to the file list.`, |
|||
type: 'warn' |
|||
}) |
|||
return |
|||
} |
|||
this.processFile(f, id, type) |
|||
this.files.push({ |
|||
convertedPath: '', |
|||
convertedSize: 0, |
|||
filename: f.name, |
|||
id, |
|||
isConverted: false, |
|||
isProcessed: false, |
|||
name, |
|||
size |
|||
}) |
|||
}) |
|||
this.$refs['fileInput'].value = null |
|||
}, |
|||
|
|||
/** |
|||
* processFile encodes a file and sends it to the backend for further |
|||
* processing. |
|||
* @param {File} file |
|||
* @param {string} id |
|||
* @param {string} type |
|||
*/ |
|||
processFile(file, id, type) { |
|||
const reader = new FileReader() |
|||
reader.onload = () => { |
|||
const name = file.name |
|||
window.backend.FileManager.HandleFile( |
|||
JSON.stringify({ |
|||
data: reader.result.split(',')[1], |
|||
ext: fExt(name), |
|||
id, |
|||
name: fName(name), |
|||
size: file.size, |
|||
type |
|||
}) |
|||
) |
|||
.then(() => { |
|||
const f = this.getFileById(id) |
|||
f.isProcessed = true |
|||
}) |
|||
.catch(err => { |
|||
this.removeFileById(id) |
|||
EventBus.$emit('notify', { |
|||
msg: err, |
|||
type: 'warn' |
|||
}) |
|||
console.error(err) |
|||
}) |
|||
} |
|||
reader.readAsDataURL(file) |
|||
}, |
|||
|
|||
/** |
|||
* removeFileById removes a file from the file list by the given ID. |
|||
* @param {string} id |
|||
*/ |
|||
removeFileById(id) { |
|||
const i = this.files.findIndex(f => f.id === id) |
|||
if (i < 0) return |
|||
this.files.splice(i, 1) |
|||
} |
|||
}, |
|||
|
|||
mounted() { |
|||
Wails.Events.On('conversion:complete', e => { |
|||
const f = this.getFileById(e.id) |
|||
if (!f) return |
|||
f.convertedPath = e.path |
|||
f.isConverted = true |
|||
f.convertedSize = e.size |
|||
}) |
|||
|
|||
Wails.Events.On('conversion:stat', e => { |
|||
const c = e.count |
|||
const r = e.resizes |
|||
const s = e.savings |
|||
const t = e.time |
|||
this.$store.dispatch('setSessionProp', { key: 'count', value: c }) |
|||
this.$store.dispatch('setSessionProp', { key: 'time', value: t }) |
|||
this.$store.dispatch('getStats') |
|||
if (s > 0) { |
|||
this.$store.dispatch('setSessionProp', { key: 'savings', value: s }) |
|||
} |
|||
if (r > 0) { |
|||
EventBus.$emit('notify', { |
|||
msg: `Optimized ${c} ${c > 1 ? 'images' : 'image'} and made ${r} ${ |
|||
r > 1 ? 'resizes' : 'resize' |
|||
} in ${prettyTime(t)[0]} ${prettyTime(t)[1].toLowerCase()}.` |
|||
}) |
|||
} else { |
|||
EventBus.$emit('notify', { |
|||
msg: `Optimized ${c} ${c > 1 ? 'images' : 'image'} in ${ |
|||
prettyTime(t)[0] |
|||
} ${prettyTime(t)[1].toLowerCase()}.` |
|||
}) |
|||
} |
|||
this.isConverting = false |
|||
}) |
|||
|
|||
const dz = this.$refs['dropZone'] |
|||
|
|||
dz.addEventListener( |
|||
'click', |
|||
() => { |
|||
this.$refs['fileInput'].click() |
|||
}, |
|||
false |
|||
) |
|||
dz.addEventListener( |
|||
'dragenter', |
|||
e => { |
|||
e.stopPropagation() |
|||
e.preventDefault() |
|||
}, |
|||
false |
|||
) |
|||
dz.addEventListener( |
|||
'dragover', |
|||
e => { |
|||
e.stopPropagation() |
|||
e.preventDefault() |
|||
if (this.isDragging) return |
|||
this.isDragging = true |
|||
}, |
|||
false |
|||
) |
|||
dz.addEventListener( |
|||
'dragend', |
|||
e => { |
|||
e.stopPropagation() |
|||
e.preventDefault() |
|||
this.isDragging = false |
|||
}, |
|||
false |
|||
) |
|||
dz.addEventListener( |
|||
'dragleave', |
|||
e => { |
|||
e.stopPropagation() |
|||
e.preventDefault() |
|||
this.isDragging = false |
|||
}, |
|||
false |
|||
) |
|||
dz.addEventListener( |
|||
'drop', |
|||
e => { |
|||
e.stopPropagation() |
|||
e.preventDefault() |
|||
const dt = e.dataTransfer |
|||
const f = dt.files |
|||
this.isDragging = false |
|||
this.processFileInput(f) |
|||
}, |
|||
false |
|||
) |
|||
|
|||
window.addEventListener('resize', this.handleWinResize) |
|||
this.handleWinResize() |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.table-wrapper { |
|||
max-width: calc(100vw - 160px); |
|||
min-height: 80px; |
|||
overflow: auto; |
|||
} |
|||
|
|||
td { |
|||
margin: 0; |
|||
padding: 0; |
|||
} |
|||
|
|||
table tr:nth-child(odd) p { |
|||
@apply bg-gray-800; |
|||
} |
|||
|
|||
td p { |
|||
@apply my-1 pl-3 py-2; |
|||
min-height: 40px; |
|||
} |
|||
|
|||
td p.cell-l { |
|||
border-top-left-radius: 6px; |
|||
border-bottom-left-radius: 6px; |
|||
} |
|||
|
|||
td p.cell-r { |
|||
border-top-right-radius: 6px; |
|||
border-bottom-right-radius: 6px; |
|||
margin-right: 1rem; |
|||
} |
|||
|
|||
.drop-zone > svg path { |
|||
transition: fill 600ms cubic-bezier(0.07, 0.95, 0, 1); |
|||
} |
|||
|
|||
.drop-zone:hover > svg path { |
|||
fill: #27ffa7; |
|||
} |
|||
</style> |
@ -0,0 +1,102 @@ |
|||
<template> |
|||
<section class="absolute bottom-0 right-0"> |
|||
<transition name="fade"> |
|||
<div v-if="notifications.length > 0" class="p-10"> |
|||
<transition-group name="fade-list"> |
|||
<div |
|||
v-for="(n, i) in notifications" |
|||
:key="`${i}-notification`" |
|||
class="bg-gray-700 border-l-2 flex mt-2 p-4 pr-6 ta rounded-br-md rounded-tr-md" |
|||
:class="n.type === 'warn' ? 'border-red' : 'border-green'" |
|||
> |
|||
<div class="mr-3"> |
|||
<!-- warn --> |
|||
<svg |
|||
v-if="n.type === 'warn'" |
|||
version="1.1" |
|||
id="warn-icon" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
x="0px" |
|||
y="0px" |
|||
viewBox="0 0 20 19" |
|||
style="enable-background:new 0 0 20 19;" |
|||
width="24" |
|||
height="24" |
|||
xml:space="preserve" |
|||
> |
|||
<path |
|||
fill="#F84D53" |
|||
d="M10.9,0.5c-0.3-0.7-1.4-0.7-1.8,0l-9,17c-0.2,0.3-0.2,0.7,0,1C0.3,18.8,0.6,19,1,19h18c0.4,0,0.7-0.2,0.9-0.5 |
|||
c0.2-0.3,0.2-0.7,0-1L10.9,0.5z M11,16H9v-2h2V16z M9,12V7h2l0,5H9z" |
|||
/> |
|||
</svg> |
|||
<!-- success --> |
|||
<svg |
|||
v-else |
|||
version="1.1" |
|||
id="check-icon" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
x="0px" |
|||
y="0px" |
|||
viewBox="0 0 24 24" |
|||
style="enable-background:new 0 0 24 24;" |
|||
width="24" |
|||
height="24" |
|||
xml:space="preserve" |
|||
> |
|||
<path |
|||
fill="#27ffa7" |
|||
d="M10,15.6l-3.3-3.3l-1.4,1.4l4.7,4.7l9.7-9.7l-1.4-1.4L10,15.6z" |
|||
/> |
|||
</svg> |
|||
</div> |
|||
<p class="text-gray-100">{{ n.msg }}</p> |
|||
</div> |
|||
</transition-group> |
|||
</div> |
|||
</transition> |
|||
</section> |
|||
</template> |
|||
|
|||
<script> |
|||
import { EventBus } from '@/lib/event-bus' |
|||
import Wails from '@wailsapp/runtime' |
|||
|
|||
export default { |
|||
name: 'Notification', |
|||
|
|||
data() { |
|||
return { |
|||
isClearing: false, |
|||
notifications: [] |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
clear() { |
|||
if (this.notifications.length <= 0) { |
|||
this.isClearing = false |
|||
return |
|||
} |
|||
this.isClearing = true |
|||
setTimeout(() => { |
|||
this.notifications.pop() |
|||
this.clear() |
|||
}, 6000) |
|||
}, |
|||
notify(n) { |
|||
this.notifications.unshift({ msg: n.msg, type: n.type }) |
|||
if (!this.isClearing) { |
|||
this.clear() |
|||
} |
|||
} |
|||
}, |
|||
|
|||
mounted() { |
|||
EventBus.$on('notify', this.notify) |
|||
Wails.Events.On('notify', this.notify) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped></style> |
@ -0,0 +1,533 @@ |
|||
<template> |
|||
<section |
|||
class="bg-gray-800 overflow-y-auto p-10 w-full" |
|||
id="settings" |
|||
ref="section" |
|||
> |
|||
<header class="flex items-center justify-between w-full"> |
|||
<h1 class="font-medium text-2xl text-green">Options</h1> |
|||
<BtnClose @click.native="closeView" /> |
|||
</header> |
|||
|
|||
<div |
|||
class="border-2 border-gray-700 flex flex-wrap my-4 p-4 rounded-md w-full" |
|||
> |
|||
<h2 class="mb-3 text-gray-200 text-xl w-full">General</h2> |
|||
<div class="flex items-center mr-6 my-2 px-4 text-gray-100"> |
|||
<p class="mr-4" v-tooltip.right-end="'Image conversion file target.'"> |
|||
Target |
|||
</p> |
|||
<Dropdown |
|||
:options="targets" |
|||
:selected="target" |
|||
class="m-0 text-gray-200" |
|||
v-on:update-option="selectTarget" |
|||
></Dropdown> |
|||
</div> |
|||
<div class="flex flex-wrap items-center mr-8 my-2 px-4 text-gray-100"> |
|||
<p v-tooltip="'The output directory for converted images.'"> |
|||
Destination |
|||
</p> |
|||
<p |
|||
@click="selectOutDir" |
|||
class="bg-gray-900 cursor-pointer font-mono hover:text-green mx-4 px-4 py-2 rounded-md ta-color-slow" |
|||
> |
|||
{{ config.outDir }} |
|||
</p> |
|||
<button |
|||
@click="openDir" |
|||
class="cursor-pointer" |
|||
v-tooltip.right-end="'Open destination directory'" |
|||
> |
|||
<svg |
|||
height="18" |
|||
id="Layer_1" |
|||
style="enable-background:new 0 0 24 24;" |
|||
version="1.1" |
|||
viewBox="0 0 24 24" |
|||
width="20" |
|||
x="0px" |
|||
xml:space="preserve" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
xmlns:xlink="http://www.w3.org/1999/xlink" |
|||
y="0px" |
|||
> |
|||
<path |
|||
d="M20,3H4C2.9,3,2,3.9,2,5v14c0,1.1,0.9,2,2,2h5v-2H4V7h16v12h-5v2h5c1.1,0,2-0.9,2-2V5C22,3.9,21.1,3,20,3z" |
|||
fill="#b3b3b3" |
|||
/> |
|||
<path d="M13,21v-5h3l-4-5l-4,5h3v5H13z" fill="#b3b3b3" /> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
<div class="flex flex-wrap items-center my-2 px-4 text-gray-100"> |
|||
<label |
|||
for="prefix" |
|||
v-tooltip.right-end="{ |
|||
content: 'A string to prepend to the converted image filename.' |
|||
}" |
|||
>Prefix</label |
|||
> |
|||
<input |
|||
@change="setConfig" |
|||
class="bg-gray-900 cursor-pointer focus:outline-none hover:text-green mx-4 px-4 py-2 rounded-md ta-color-slow" |
|||
id="prefix" |
|||
maxlength="16" |
|||
type="text" |
|||
v-model="config.prefix" |
|||
/> |
|||
</div> |
|||
<div class="flex flex-wrap items-center my-2 px-4 text-gray-100"> |
|||
<label |
|||
for="suffix" |
|||
v-tooltip.right-end="{ |
|||
content: 'A string to append to the converted image filename.' |
|||
}" |
|||
>Suffix</label |
|||
> |
|||
<input |
|||
@change="setConfig" |
|||
class="bg-gray-900 cursor-pointer focus:outline-none hover:text-green mx-4 px-4 py-2 rounded-md ta-color-slow" |
|||
id="suffix" |
|||
maxlength="16" |
|||
type="text" |
|||
v-model="config.suffix" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
class="border-2 border-gray-700 flex flex-wrap my-4 px-2 py-4 rounded-md w-full" |
|||
> |
|||
<header class="flex px-2 w-full"> |
|||
<h2 class="text-gray-200 text-xl">Resizing</h2> |
|||
<button |
|||
@click="addSize" |
|||
class="border-0 focus:outline-none hover:green hover:text-green ml-8 ta-slow" |
|||
v-tooltip.right-end="{ |
|||
content: 'Add an image size to convert.', |
|||
delay: 600 |
|||
}" |
|||
> |
|||
Add Size + |
|||
</button> |
|||
</header> |
|||
<div |
|||
:key="i" |
|||
class="flex flex-wrap items-center my-2 px-4 text-gray-100 w-full" |
|||
v-for="(s, i) in config.sizes" |
|||
> |
|||
<div class="mb-2 lg:mb-0 px-2 w-full sm:w-1/2 lg:w-auto"> |
|||
<label :for="`width-${i}`" class="block xl:inline-block mb-2 mr-4" |
|||
><span |
|||
v-tooltip.right-end=" |
|||
'Width in pixels. Required and must be a positive integer.' |
|||
" |
|||
>Width</span |
|||
></label |
|||
> |
|||
<input |
|||
:id="`width-${i}`" |
|||
@blur="setConfig" |
|||
@input="handleNumber" |
|||
class="bg-gray-900 cursor-pointer focus:outline-none hover:text-green px-4 py-2 rounded-md ta-color-slow w-full lg:w-auto" |
|||
type="text" |
|||
v-model.number="s.width" |
|||
/> |
|||
</div> |
|||
<div class="mb-2 lg:mb-0 px-2 w-full sm:w-1/2 lg:w-auto"> |
|||
<label :for="`height-${i}`" class="block xl:inline-block mb-2 mr-4" |
|||
><span |
|||
v-tooltip.right-end=" |
|||
'Height in pixels. Required and must be a positive integer.' |
|||
" |
|||
>Height</span |
|||
></label |
|||
> |
|||
<input |
|||
:id="`height-${i}`" |
|||
@blur="setConfig" |
|||
@input="handleNumber" |
|||
class="bg-gray-900 cursor-pointer focus:outline-none hover:text-green px-4 py-2 rounded-md ta-color-slow w-full lg:w-auto" |
|||
type="text" |
|||
v-model.number="s.height" |
|||
/> |
|||
</div> |
|||
<div class="mb-2 lg:mb-0 px-2 w-full sm:w-1/2 lg:w-auto"> |
|||
<label class="block xl:inline-block mb-2 mr-4">Strategy</label> |
|||
<div class="flex lg:inline-flex w-full lg:w-auto"> |
|||
<Dropdown |
|||
:options="strategies" |
|||
:selected="strategy(s)" |
|||
@click.native="handleSelectStrategy(i)" |
|||
class="my-0 mr-4 text-gray-200 w-full" |
|||
v-on:update-option="selectStrategy" |
|||
v-tooltip="strategyTooltip(s)" |
|||
></Dropdown> |
|||
<button @click="removeSize(i)"> |
|||
<svg |
|||
height="10" |
|||
id="x" |
|||
style="enable-background:new 0 0 11.9 11.9;" |
|||
version="1.1" |
|||
viewBox="0 0 11.9 11.9" |
|||
width="10" |
|||
x="0px" |
|||
xml:space="preserve" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
xmlns:xlink="http://www.w3.org/1999/xlink" |
|||
y="0px" |
|||
> |
|||
<path |
|||
d="M10.4,0L6,4.5L1.5,0L0,1.5L4.5,6L0,10.4l1.5,1.5L6,7.5l4.5,4.5l1.5-1.5L7.5,6l4.5-4.5L10.4,0z" |
|||
fill="#b3b3b3" |
|||
/> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
class="border-2 border-gray-700 flex flex-wrap my-4 p-4 rounded-md w-full" |
|||
> |
|||
<h2 class="mb-3 text-gray-200 text-xl w-full">WebP</h2> |
|||
<div class="px-4 text-gray-100 w-1/2"> |
|||
<div class="flex items-center w-full"> |
|||
<p class="mr-6" v-tooltip.right-end="{ content: tooltips.quality }"> |
|||
Quality |
|||
</p> |
|||
<div class="w-full"> |
|||
<vue-slider @change="setConfig" v-model="config.webpOpt.quality" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="px-4 text-gray-100 w-1/2"> |
|||
<div class="flex items-center w-full"> |
|||
<p class="mr-4">Lossless</p> |
|||
<div |
|||
@click="toggleWebpLossless" |
|||
class="bg-gray-900 check-wrapper flex items-center justify-center rounded-md" |
|||
> |
|||
<transition mode="out-in" name="fade"> |
|||
<svg |
|||
height="24" |
|||
id="check-icon" |
|||
style="enable-background:new 0 0 24 24;" |
|||
v-if="config.webpOpt.lossless" |
|||
version="1.1" |
|||
viewBox="0 0 24 24" |
|||
width="24" |
|||
x="0px" |
|||
xml:space="preserve" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
y="0px" |
|||
> |
|||
<path |
|||
d="M10,15.6l-3.3-3.3l-1.4,1.4l4.7,4.7l9.7-9.7l-1.4-1.4L10,15.6z" |
|||
fill="#27ffa7" |
|||
/> |
|||
</svg> |
|||
</transition> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
class="border-2 border-gray-700 flex flex-wrap my-4 p-4 rounded-md w-full" |
|||
> |
|||
<h2 class="mb-3 text-gray-200 text-xl w-full">JPEG</h2> |
|||
<div class="px-4 text-gray-100 w-1/2"> |
|||
<div class="flex items-center w-full"> |
|||
<p class="mr-6" v-tooltip.right-end="{ content: tooltips.quality }"> |
|||
Quality |
|||
</p> |
|||
<div class="w-full"> |
|||
<vue-slider @change="setConfig" v-model="config.jpegOpt.quality" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
class="border-2 border-gray-700 flex flex-wrap my-4 p-4 rounded-md w-full" |
|||
> |
|||
<h2 class="mb-3 text-gray-200 text-xl w-full">PNG</h2> |
|||
<div class="px-4 text-gray-100 w-1/2"> |
|||
<div class="flex items-center w-full"> |
|||
<p |
|||
class="mr-6" |
|||
title="Specify the image conversion quality." |
|||
v-tooltip.right-end="{ content: tooltips.quality }" |
|||
> |
|||
Quality |
|||
</p> |
|||
<div class="w-full"> |
|||
<vue-slider @change="setConfig" v-model="config.pngOpt.quality" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="mb-4 w-full"> |
|||
<button |
|||
@click="restoreDefaults" |
|||
class="border-gray-400 btn focus:outline-none hover:bg-green hover:border-green hover:text-gray-900 ml-auto ta-slow" |
|||
> |
|||
Restore Defaults |
|||
</button> |
|||
</div> |
|||
</section> |
|||
</template> |
|||
|
|||
<script> |
|||
import BtnClose from './BtnClose' |
|||
import Dropdown from './Dropdown' |
|||
import VueSlider from 'vue-slider-component' |
|||
import 'vue-slider-component/theme/antd.css' |
|||
import { EventBus } from '@/lib/event-bus' |
|||
|
|||
export default { |
|||
name: 'Settings', |
|||
|
|||
components: { BtnClose, Dropdown, VueSlider }, |
|||
|
|||
data() { |
|||
return { |
|||
activeStrategy: 0, |
|||
strategies: [ |
|||
{ name: 'Fill', value: 0 }, |
|||
{ name: 'Fit', value: 1 }, |
|||
{ name: 'Smart Crop', value: 2 } |
|||
], |
|||
targets: [ |
|||
{ name: 'WebP', value: 'webp' }, |
|||
{ |
|||
name: 'JPEG', |
|||
value: 'jpg' |
|||
}, |
|||
{ name: 'PNG', value: 'png' } |
|||
], |
|||
tooltips: { |
|||
quality: |
|||
'Image quality of the converted image where 0 is the lowest and 100 is the highest.' |
|||
} |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
/** |
|||
* config returns the app configuration from state. |
|||
* @returns {object} |
|||
*/ |
|||
config() { |
|||
return this.$store.getters.config |
|||
}, |
|||
|
|||
target() { |
|||
return ( |
|||
this.targets.find(t => this.config.target === t.value) || { |
|||
name: '', |
|||
value: '' |
|||
} |
|||
) |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
/** |
|||
* addSize adds a blank size to the size config. |
|||
*/ |
|||
addSize() { |
|||
this.$store.dispatch('addSize') |
|||
}, |
|||
|
|||
/** |
|||
* removeSize removes the selected size rect from the sizes config. |
|||
* @param {number} i - The size index. |
|||
*/ |
|||
removeSize(i) { |
|||
this.$store.dispatch('removeSize', i) |
|||
this.setConfig() |
|||
}, |
|||
|
|||
/** |
|||
* handleSelectStrategy records the index of the currently active size input. |
|||
* @param {number} i - The size index. |
|||
*/ |
|||
handleSelectStrategy(i) { |
|||
this.activeStrategy = i |
|||
}, |
|||
|
|||
/** |
|||
* selectStrategy updates the configured size with the selected strategy. |
|||
* @param {object} e - The selected strategy. |
|||
*/ |
|||
selectStrategy(e) { |
|||
this.$store |
|||
.dispatch('setSizeStrategy', { |
|||
index: this.activeStrategy, |
|||
value: e.value |
|||
}) |
|||
.then(() => { |
|||
this.setConfig() |
|||
}) |
|||
.catch(err => { |
|||
console.log(err) |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* strategy returns the strategy from the list of strategies matching the |
|||
* current strategy. |
|||
* @param {object} s - The current size. |
|||
* @returns {object} The matching strategy. |
|||
*/ |
|||
strategy(s) { |
|||
return this.strategies.find(o => s.strategy === o.value) |
|||
}, |
|||
|
|||
/** |
|||
* strategyTooltip returns a tooltip content string based on the selected |
|||
* strategy. |
|||
* @param {object} s - Current size. |
|||
* @returns string The tooltip content. |
|||
*/ |
|||
strategyTooltip(s) { |
|||
switch (s.strategy) { |
|||
case 0: { |
|||
return 'Crop resizes the image to the specified dimensions, cropping excess from the center.' |
|||
} |
|||
case 1: { |
|||
return "Fit resizes the image to fit within the specified dimensions, maintaining the image's aspect ratio." |
|||
} |
|||
default: { |
|||
return '' |
|||
} |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* closeView closes the current view. |
|||
*/ |
|||
closeView() { |
|||
this.$emit('close-view') |
|||
}, |
|||
|
|||
/** |
|||
* handleNumber validates a number input and displays an error message if |
|||
* the input cannot be parsed. |
|||
* @param {InputEvent} e |
|||
*/ |
|||
handleNumber(e) { |
|||
if (!e.data) return |
|||
const n = parseInt(e.data, 10) |
|||
if (isNaN(n)) { |
|||
EventBus.$emit('notify', { |
|||
msg: `Image size must be a number.`, |
|||
type: 'warn' |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* openDir opens the configured output directory. |
|||
*/ |
|||
openDir() { |
|||
window.backend.Config.OpenOutputDir() |
|||
.then(res => { |
|||
console.log(res) |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* restoreDefaults resets the app configuration to defaults. |
|||
*/ |
|||
restoreDefaults() { |
|||
window.backend.Config.RestoreDefaults() |
|||
.then(() => { |
|||
this.$store.dispatch('getConfig') |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* selectOutDir selects an output directory in the config. |
|||
*/ |
|||
selectOutDir() { |
|||
window.backend.Config.SetOutDir() |
|||
.then(res => { |
|||
console.log(res) |
|||
this.$store.dispatch('getConfig') |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* selectTarget updates the config target. |
|||
* @param {object} e - The selected target. |
|||
*/ |
|||
selectTarget(e) { |
|||
this.$store |
|||
.dispatch('setConfigProp', { key: 'target', value: e.value }) |
|||
.then(() => { |
|||
this.setConfig() |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
|
|||
/** |
|||
* setConfig updates the configuration with the current state. |
|||
*/ |
|||
setConfig() { |
|||
this.$store.dispatch('setConfig', this.config) |
|||
}, |
|||
|
|||
/** |
|||
* toggleWebpLossless toggles the lossless property of the WebP config. |
|||
*/ |
|||
toggleWebpLossless() { |
|||
this.$store |
|||
.dispatch('toggleWebpLossless') |
|||
.then(() => { |
|||
this.setConfig() |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
input:focus { |
|||
color: #27ffa7; |
|||
} |
|||
|
|||
button > svg > path { |
|||
transition: fill 0.6s cubic-bezier(0.07, 0.95, 0, 1); |
|||
} |
|||
|
|||
button:hover > svg > path { |
|||
fill: #27ffa7; |
|||
} |
|||
|
|||
.check-wrapper { |
|||
border: 2px solid transparent; |
|||
cursor: pointer; |
|||
width: 24px; |
|||
height: 24px; |
|||
} |
|||
</style> |
@ -0,0 +1,152 @@ |
|||
<template> |
|||
<nav class="bg-gray-900 flex flex-col items-center pt-10 w-24 z-40"> |
|||
<div |
|||
@click="selectView('Settings')" |
|||
class="cursor-pointer mb-2 p-2 rounded-full w-10 h-10" |
|||
> |
|||
<svg |
|||
version="1.1" |
|||
id="cog" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
:class="{ active: active === 'Settings' }" |
|||
xmlns:xlink="http://www.w3.org/1999/xlink" |
|||
x="0px" |
|||
y="0px" |
|||
viewBox="0 0 19.6 20" |
|||
enable-background="new 0 0 19.6 20" |
|||
xml:space="preserve" |
|||
> |
|||
<path |
|||
fill="#b3b3b3" |
|||
d="M0.1,13.3l2,3.5c0.3,0.5,0.9,0.6,1.4,0.4l1.4-0.8c0.6,0.5,1.2,0.8,1.9,1.1V19c0,0.6,0.4,1,1,1h4 |
|||
c0.6,0,1-0.4,1-1v-1.6c0.7-0.3,1.3-0.7,1.9-1.1l1.4,0.8c0.5,0.3,1.1,0.1,1.4-0.4l2-3.5c0.3-0.5,0.1-1.1-0.4-1.4l-1.4-0.8 |
|||
c0.1-0.4,0.1-0.7,0.1-1.1s0-0.7-0.1-1.1l1.4-0.8c0.5-0.3,0.6-0.9,0.4-1.4l-2-3.5c-0.3-0.5-0.9-0.6-1.4-0.4l-1.4,0.8 |
|||
c-0.6-0.5-1.2-0.8-1.9-1.1V1c0-0.6-0.4-1-1-1h-4c-0.6,0-1,0.4-1,1v1.6C6.1,2.9,5.5,3.3,4.9,3.7L3.5,2.9C3,2.6,2.4,2.8,2.1,3.3 |
|||
l-2,3.5C-0.1,7.2,0,7.8,0.5,8.1l1.4,0.8C1.8,9.3,1.8,9.6,1.8,10s0,0.7,0.1,1.1l-1.4,0.8C0,12.2-0.1,12.8,0.1,13.3z M9.8,6 |
|||
c2.2,0,4,1.8,4,4s-1.8,4-4,4s-4-1.8-4-4S7.6,6,9.8,6z" |
|||
/> |
|||
</svg> |
|||
</div> |
|||
<div |
|||
@click="selectView('Stats')" |
|||
class="cursor-pointer mb-4 p-2 rounded-full w-10 h-10" |
|||
> |
|||
<svg |
|||
version="1.1" |
|||
id="graph" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
:class="{ active: active === 'Stats' }" |
|||
xmlns:xlink="http://www.w3.org/1999/xlink" |
|||
x="0px" |
|||
y="0px" |
|||
viewBox="0 0 17 20" |
|||
style="enable-background:new 0 0 17 20;" |
|||
xml:space="preserve" |
|||
> |
|||
<rect y="10" fill="#b3b3b3" width="2" height="10" /> |
|||
<rect x="5" y="2" fill="#b3b3b3" width="2" height="18" /> |
|||
<rect x="10" y="5" fill="#b3b3b3" width="2" height="15" /> |
|||
<rect x="15" fill="#b3b3b3" width="2" height="20" /> |
|||
</svg> |
|||
</div> |
|||
<div |
|||
@click="selectView('About')" |
|||
class="cursor-pointer mb-2 p-2 rounded-full w-10 h-10" |
|||
> |
|||
<svg |
|||
version="1.1" |
|||
id="info" |
|||
:class="{ active: active === 'About' }" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
xmlns:xlink="http://www.w3.org/1999/xlink" |
|||
x="0px" |
|||
y="0px" |
|||
viewBox="0 0 20 20" |
|||
style="enable-background:new 0 0 20 20;" |
|||
xml:space="preserve" |
|||
> |
|||
<g> |
|||
<path |
|||
fill="#b3b3b3" |
|||
d="M10,0C4.5,0,0,4.5,0,10s4.5,10,10,10s10-4.5,10-10S15.5,0,10,0z M10,18c-4.4,0-8-3.6-8-8s3.6-8,8-8s8,3.6,8,8 |
|||
S14.4,18,10,18z" |
|||
/> |
|||
<path fill="#b3b3b3" d="M9,9h2v6H9V9z M9,5h2v2H9V5z" /> |
|||
</g> |
|||
</svg> |
|||
</div> |
|||
</nav> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: 'Sidebar', |
|||
|
|||
props: { |
|||
active: { |
|||
type: String, |
|||
required: true |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
selectView(v) { |
|||
this.$emit('select-view', v) |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
#cog, |
|||
#info { |
|||
transition: transform 0.3s cubic-bezier(0.07, 0.95, 0, 1); |
|||
} |
|||
|
|||
#cog path, |
|||
#graph rect, |
|||
#info path { |
|||
transition: fill 1s cubic-bezier(0.07, 0.95, 0, 1); |
|||
} |
|||
|
|||
#cog.active { |
|||
transform: rotate(270deg); |
|||
} |
|||
|
|||
#cog.active path, |
|||
#info.active path { |
|||
fill: #27ffa7; |
|||
} |
|||
|
|||
#info.active path { |
|||
fill: #d690ff; |
|||
} |
|||
|
|||
#graph:hover rect:first-of-type, |
|||
#graph.active rect:first-of-type { |
|||
fill: #27d1ff; |
|||
} |
|||
|
|||
#graph:hover rect:nth-of-type(2), |
|||
#graph.active rect:nth-of-type(2) { |
|||
fill: #27ffa7; |
|||
} |
|||
|
|||
#graph:hover rect:nth-of-type(3), |
|||
#graph.active rect:nth-of-type(3) { |
|||
fill: #ffe027; |
|||
} |
|||
|
|||
#graph:hover rect:nth-of-type(4), |
|||
#graph.active rect:nth-of-type(4) { |
|||
fill: #ff9b45; |
|||
} |
|||
|
|||
div:hover > #cog path { |
|||
fill: #27ffa7; |
|||
} |
|||
|
|||
div:hover > #info path { |
|||
fill: #d690ff; |
|||
} |
|||
</style> |
@ -0,0 +1,103 @@ |
|||
<template> |
|||
<section class="bg-gray-800 overflow-y-auto p-10 w-full"> |
|||
<header class="flex items-center justify-between w-full"> |
|||
<h1 class="font-medium text-2xl text-blue">Stats</h1> |
|||
<BtnClose @click.native="closeView" color="blue" /> |
|||
</header> |
|||
<div class="mt-6 w-full"> |
|||
<h2 class="mb-3 text-gray-300 text-xl w-full">Current Session</h2> |
|||
<div class="flex flex-wrap w-full"> |
|||
<div class="w-full sm:w-1/3 xl:w-64"> |
|||
<p |
|||
class="font-bold leading-none text-4xl md:text-5xl text-green tracking-tight" |
|||
> |
|||
{{ session.savings }} |
|||
</p> |
|||
<p class="font-medium text-gray-200 tracking-wider uppercase"> |
|||
Saved |
|||
</p> |
|||
</div> |
|||
<div class="w-full sm:w-1/3 xl:w-64"> |
|||
<p |
|||
class="font-bold leading-none text-4xl md:text-5xl text-blue tracking-tight" |
|||
> |
|||
{{ session.count }} |
|||
</p> |
|||
<p class="font-medium text-gray-200 tracking-wider uppercase"> |
|||
Images Optimized |
|||
</p> |
|||
</div> |
|||
<div class="w-full sm:w-1/3 xl:w-64"> |
|||
<p |
|||
class="font-bold leading-none text-4xl md:text-5xl text-purple tracking-tight" |
|||
> |
|||
{{ session.time[0] }} |
|||
</p> |
|||
<p class="font-medium text-gray-200 tracking-wider uppercase"> |
|||
{{ session.time[1] }} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="mt-8 w-full"> |
|||
<h2 class="mb-3 text-gray-300 text-xl w-full">All Time</h2> |
|||
<div class="flex flex-wrap w-full"> |
|||
<div class="w-full sm:w-1/3 xl:w-64"> |
|||
<p |
|||
class="font-bold leading-none text-4xl md:text-5xl text-orange tracking-tight" |
|||
> |
|||
{{ stats.byteCount }} |
|||
</p> |
|||
<p class="font-medium text-gray-200 tracking-wider uppercase"> |
|||
Saved |
|||
</p> |
|||
</div> |
|||
<div class="w-full sm:w-1/3 xl:w-64"> |
|||
<p |
|||
class="font-bold leading-none text-4xl md:text-5xl text-yellow tracking-tight" |
|||
> |
|||
{{ stats.imageCount }} |
|||
</p> |
|||
<p class="font-medium text-gray-200 tracking-wider uppercase"> |
|||
Images Optimized |
|||
</p> |
|||
</div> |
|||
<div class="w-full sm:w-1/3 xl:w-64"> |
|||
<p |
|||
class="font-bold leading-none text-4xl md:text-5xl text-pink tracking-tight" |
|||
> |
|||
{{ stats.timeCount[0] }} |
|||
</p> |
|||
<p class="font-medium text-gray-200 tracking-wider uppercase"> |
|||
{{ stats.timeCount[1] }} |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
</template> |
|||
|
|||
<script> |
|||
import BtnClose from './BtnClose' |
|||
|
|||
export default { |
|||
name: 'Stats', |
|||
|
|||
components: { BtnClose }, |
|||
|
|||
computed: { |
|||
session() { |
|||
return this.$store.getters.session |
|||
}, |
|||
stats() { |
|||
return this.$store.getters.stats |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
closeView() { |
|||
this.$emit('close-view') |
|||
} |
|||
} |
|||
} |
|||
</script> |
@ -0,0 +1,3 @@ |
|||
import Vue from 'vue' |
|||
|
|||
export const EventBus = new Vue() |
@ -0,0 +1,35 @@ |
|||
/** |
|||
* fExt returns the extension of a given file. |
|||
* @param {string} filename - The filename. |
|||
* @returns {string} |
|||
*/ |
|||
export function fExt(filename) { |
|||
return filename.split('.').pop() |
|||
} |
|||
|
|||
/** |
|||
* fName returns the name of a given file without its extension. |
|||
* @param {string} filename - The filename. |
|||
* @returns {string} |
|||
*/ |
|||
export function fName(filename) { |
|||
filename = filename.replace(/\\/g, '/') |
|||
return filename.substring( |
|||
filename.lastIndexOf('/') + 1, |
|||
filename.lastIndexOf('.') |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* fSize returns a pretty string from a number of bytes. |
|||
* For example, 1024 converts to "1 MB" |
|||
* @param {number} bytes - File size in bytes. |
|||
* @returns {string} |
|||
*/ |
|||
export function fSize(bytes) { |
|||
if (bytes === 0) { |
|||
return '0.00 B' |
|||
} |
|||
const e = Math.floor(Math.log(bytes) / Math.log(1024)) |
|||
return (bytes / Math.pow(1024, e)).toFixed(2) + ' ' + ' KMGTP'.charAt(e) + 'B' |
|||
} |
@ -0,0 +1,23 @@ |
|||
/** |
|||
* prettyTime returns a human-friendly time representation from milliseconds. |
|||
* @param {number} ms |
|||
* @returns {(string)[]} |
|||
*/ |
|||
export function prettyTime(ms) { |
|||
const seconds = (ms / 1000).toFixed(1) |
|||
const minutes = (ms / (1000 * 60)).toFixed(1) |
|||
const hours = (ms / (1000 * 60 * 60)).toFixed(1) |
|||
const days = (ms / (1000 * 60 * 60 * 24)).toFixed(1) |
|||
|
|||
if (ms < 1000) { |
|||
return [ms, 'Milliseconds'] |
|||
} else if (seconds < 60) { |
|||
return [seconds, 'Seconds'] |
|||
} else if (minutes < 60) { |
|||
return [minutes, 'Minutes'] |
|||
} else if (hours < 24) { |
|||
return [hours, 'Hours'] |
|||
} else { |
|||
return [days, 'Days'] |
|||
} |
|||
} |
@ -0,0 +1,19 @@ |
|||
import 'core-js/stable' |
|||
import 'regenerator-runtime/runtime' |
|||
import Vue from 'vue' |
|||
import App from './App.vue' |
|||
import store from './store' |
|||
import * as Wails from '@wailsapp/runtime' |
|||
import VTooltip from 'v-tooltip' |
|||
|
|||
Vue.use(VTooltip, { defaultDelay: 600, defaultOffset: 16 }) |
|||
|
|||
Vue.config.productionTip = false |
|||
Vue.config.devtools = true |
|||
|
|||
Wails.Init(() => { |
|||
new Vue({ |
|||
render: h => h(App), |
|||
store |
|||
}).$mount('#app') |
|||
}) |
@ -0,0 +1,147 @@ |
|||
import Vue from 'vue' |
|||
import Vuex from 'vuex' |
|||
import { fSize } from './lib/file' |
|||
import { prettyTime } from './lib/time' |
|||
|
|||
Vue.use(Vuex) |
|||
|
|||
const store = new Vuex.Store({ |
|||
state: { |
|||
config: { |
|||
outDir: '', |
|||
target: '', |
|||
prefix: '', |
|||
suffix: '', |
|||
sizes: [], |
|||
jpegOpt: { quality: 0 }, |
|||
pngOpt: { quality: 0 }, |
|||
webpOpt: { lossless: false, quality: 0 } |
|||
}, |
|||
stats: { |
|||
byteCount: 0, |
|||
imageCount: 0, |
|||
timeCount: 0 |
|||
}, |
|||
session: { |
|||
count: 0, |
|||
savings: 0, |
|||
time: 0 |
|||
} |
|||
}, |
|||
getters: { |
|||
config(state) { |
|||
return state.config |
|||
}, |
|||
|
|||
session(state) { |
|||
return { |
|||
count: state.session.count, |
|||
hasSavings: state.session.savings > 0, |
|||
savings: fSize(state.session.savings), |
|||
time: prettyTime(state.session.time) |
|||
} |
|||
}, |
|||
|
|||
stats(state) { |
|||
return { |
|||
byteCount: fSize(state.stats.byteCount), |
|||
imageCount: state.stats.imageCount, |
|||
timeCount: prettyTime(state.stats.timeCount) |
|||
} |
|||
} |
|||
}, |
|||
actions: { |
|||
addSize(context) { |
|||
context.commit('addSize') |
|||
}, |
|||
removeSize(context, index) { |
|||
context.commit('removeSize', index) |
|||
}, |
|||
setSizeStrategy(context, payload) { |
|||
context.commit('setSizeStrategy', payload) |
|||
}, |
|||
|
|||
getConfig(context) { |
|||
window.backend.Config.GetAppConfig() |
|||
.then(cfg => { |
|||
context.commit('setConfig', cfg) |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
|
|||
setConfig(context, c) { |
|||
window.backend.Config.SetConfig(JSON.stringify(c)) |
|||
.then(() => { |
|||
context.dispatch('getConfig') |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
|
|||
setConfigProp(context, payload) { |
|||
context.commit('setConfigProp', payload) |
|||
}, |
|||
|
|||
setSessionProp(context, payload) { |
|||
context.commit('setSessionProp', payload) |
|||
}, |
|||
|
|||
toggleWebpLossless(context) { |
|||
context.commit('toggleWebpLossless') |
|||
}, |
|||
|
|||
getStats(context) { |
|||
window.backend.Stat.GetStats() |
|||
.then(s => { |
|||
context.commit('setStats', s) |
|||
}) |
|||
.catch(err => { |
|||
console.error(err) |
|||
}) |
|||
}, |
|||
setStats(context, s) { |
|||
context.commit('setStats', s) |
|||
} |
|||
}, |
|||
mutations: { |
|||
addSize(state) { |
|||
const s = { height: null, width: null, strategy: 0 } |
|||
if (!state.config.sizes) { |
|||
state.config.sizes = [s] |
|||
} else { |
|||
state.config.sizes.push(s) |
|||
} |
|||
}, |
|||
removeSize(state, index) { |
|||
state.config.sizes.splice(index, 1) |
|||
}, |
|||
setSizeStrategy(state, payload) { |
|||
state.config.sizes[payload.index].strategy = payload.value |
|||
}, |
|||
|
|||
setConfig(state, c) { |
|||
state.config = c |
|||
}, |
|||
|
|||
setConfigProp(state, payload) { |
|||
state.config[payload.key] = payload.value |
|||
}, |
|||
|
|||
setSessionProp(state, payload) { |
|||
state.session[payload.key] += payload.value |
|||
}, |
|||
|
|||
setStats(state, s) { |
|||
state.stats = s |
|||
}, |
|||
|
|||
toggleWebpLossless(state) { |
|||
state.config.webpOpt.lossless = !state.config.webpOpt.lossless |
|||
} |
|||
} |
|||
}) |
|||
|
|||
export default store |
@ -0,0 +1,40 @@ |
|||
module.exports = { |
|||
purge: ['./src/**/*.vue'], |
|||
theme: { |
|||
colors: { |
|||
blue: { |
|||
default: '#27d1ff' |
|||
}, |
|||
gray: { |
|||
100: '#f4f5f9', |
|||
200: '#b3b3b3', |
|||
300: '#808080', |
|||
400: '#666666', |
|||
700: '#3a3a42', |
|||
800: '#212128', |
|||
900: '#18181f' |
|||
}, |
|||
green: { |
|||
default: '#27ffa7' |
|||
}, |
|||
orange: { |
|||
default: '#ff9b45' |
|||
}, |
|||
pink: { |
|||
default: '#ff45bd' |
|||
}, |
|||
purple: { |
|||
400: '#d690ff', |
|||
default: '#ba45ff' |
|||
}, |
|||
red: { |
|||
default: '#f84d53' |
|||
}, |
|||
yellow: { |
|||
default: '#ffe027' |
|||
} |
|||
} |
|||
}, |
|||
variants: {}, |
|||
plugins: [] |
|||
} |
@ -0,0 +1,42 @@ |
|||
let cssConfig = {} |
|||
|
|||
if (process.env.NODE_ENV === 'production') { |
|||
cssConfig = { |
|||
extract: { |
|||
filename: '[name].css', |
|||
chunkFilename: '[name].css' |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
chainWebpack: config => { |
|||
let limit = 9999999999999999 |
|||
config.module |
|||
.rule('images') |
|||
.test(/\.(png|gif|jpg)(\?.*)?$/i) |
|||
.use('url-loader') |
|||
.loader('url-loader') |
|||
.tap(options => Object.assign(options, { limit: limit })) |
|||
config.module |
|||
.rule('fonts') |
|||
.test(/\.(woff2?|eot|ttf|otf|svg)(\?.*)?$/i) |
|||
.use('url-loader') |
|||
.loader('url-loader') |
|||
.options({ |
|||
limit: limit |
|||
}) |
|||
}, |
|||
css: cssConfig, |
|||
configureWebpack: { |
|||
output: { |
|||
filename: '[name].js' |
|||
}, |
|||
optimization: { |
|||
splitChunks: false |
|||
} |
|||
}, |
|||
devServer: { |
|||
disableHostCheck: true |
|||
} |
|||
} |
@ -0,0 +1,21 @@ |
|||
module optimus |
|||
|
|||
go 1.16 |
|||
|
|||
require ( |
|||
github.com/Masterminds/semver v1.5.0 // indirect |
|||
github.com/chai2010/webp v1.1.0 |
|||
github.com/disintegration/imaging v1.6.2 |
|||
github.com/fatih/color v1.9.0 // indirect |
|||
github.com/foobaz/lossypng v0.0.0-20200814224715-48fa8819852a |
|||
github.com/leaanthony/slicer v1.4.1 // indirect |
|||
github.com/mattn/go-colorable v0.1.7 // indirect |
|||
github.com/muesli/smartcrop v0.3.0 |
|||
github.com/pkg/errors v0.9.1 // indirect |
|||
github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 |
|||
github.com/wailsapp/wails v1.16.5 |
|||
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 // indirect |
|||
golang.org/x/net v0.0.0-20200930145003-4acb6c075d10 // indirect |
|||
golang.org/x/sys v0.0.0-20200929083018-4d22bbb62b3c // indirect |
|||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect |
|||
) |
@ -0,0 +1,111 @@ |
|||
github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= |
|||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= |
|||
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= |
|||
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= |
|||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= |
|||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= |
|||
github.com/chai2010/webp v1.1.0 h1:4Ei0/BRroMF9FaXDG2e4OxwFcuW2vcXd+A6tyqTJUQQ= |
|||
github.com/chai2010/webp v1.1.0/go.mod h1:LP12PG5IFmLGHUU26tBiCBKnghxx3toZFwDjOYvd3Ow= |
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= |
|||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= |
|||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= |
|||
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= |
|||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= |
|||
github.com/foobaz/lossypng v0.0.0-20200814224715-48fa8819852a h1:0TYY/syyvt/+y5PWAkybgG2o6zHY+UrI3fuixaSeRoI= |
|||
github.com/foobaz/lossypng v0.0.0-20200814224715-48fa8819852a/go.mod h1:wRxTcIExb9GZAgOr1wrQuOZBkyoZNQi7znUmeyKTciA= |
|||
github.com/go-playground/colors v1.2.0 h1:0EdjTXKrr2g1L/LQTYtIqabeHpZuGZz1U4osS1T8+5M= |
|||
github.com/go-playground/colors v1.2.0/go.mod h1:miw1R2JIE19cclPxsXqNdzLZsk4DP4iF+m88bRc7kfM= |
|||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= |
|||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= |
|||
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= |
|||
github.com/jackmordaunt/icns v1.0.0 h1:RYSxplerf/l/DUd09AHtITwckkv/mqjVv4DjYdPmAMQ= |
|||
github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo= |
|||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= |
|||
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= |
|||
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= |
|||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= |
|||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= |
|||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= |
|||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= |
|||
github.com/leaanthony/slicer v1.4.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY= |
|||
github.com/leaanthony/slicer v1.4.1 h1:X/SmRIDhkUAolP79mSTO0jTcVX1k504PJBqvV6TwP0w= |
|||
github.com/leaanthony/slicer v1.4.1/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY= |
|||
github.com/leaanthony/spinner v0.5.3 h1:IMTvgdQCec5QA4qRy0wil4XsRP+QcG1OwLWVK/LPZ5Y= |
|||
github.com/leaanthony/spinner v0.5.3/go.mod h1:oHlrvWicr++CVV7ALWYi+qHk/XNA91D9IJ48IqmpVUo= |
|||
github.com/leaanthony/synx v0.1.0 h1:R0lmg2w6VMb8XcotOwAe5DLyzwjLrskNkwU7LLWsyL8= |
|||
github.com/leaanthony/synx v0.1.0/go.mod h1:Iz7eybeeG8bdq640iR+CwYb8p+9EOsgMWghkSRyZcqs= |
|||
github.com/leaanthony/wincursor v0.1.0 h1:Dsyp68QcF5cCs65AMBmxoYNEm0n8K7mMchG6a8fYxf8= |
|||
github.com/leaanthony/wincursor v0.1.0/go.mod h1:7TVwwrzSH/2Y9gLOGH+VhA+bZhoWXBRgbGNTMk+yimE= |
|||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= |
|||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= |
|||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= |
|||
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= |
|||
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= |
|||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= |
|||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= |
|||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= |
|||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= |
|||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= |
|||
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= |
|||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= |
|||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= |
|||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= |
|||
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc= |
|||
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= |
|||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= |
|||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= |
|||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98= |
|||
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= |
|||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= |
|||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= |
|||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= |
|||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
|||
github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= |
|||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= |
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
|||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
|||
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= |
|||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= |
|||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= |
|||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |
|||
github.com/syossan27/tebata v0.0.0-20180602121909-b283fe4bc5ba h1:2DHfQOxcpWdGf5q5IzCUFPNvRX9Icf+09RvQK2VnJq0= |
|||
github.com/syossan27/tebata v0.0.0-20180602121909-b283fe4bc5ba/go.mod h1:iLnlXG2Pakcii2CU0cbY07DRCSvpWNa7nFxtevhOChk= |
|||
github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 h1:Ah2/69Z24rwD6OByyOdpJDmttftz0FTF8Q4QZ/SF1E4= |
|||
github.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68/go.mod h1:EqKqAeKddSL9XSGnfXd/7iLncccKhR16HBKVva7ENw8= |
|||
github.com/wailsapp/wails v1.16.5 h1:6kGXCeiTwQsm/vkKqtr/StzH2BRXV/uBZe6afUSuWbg= |
|||
github.com/wailsapp/wails v1.16.5/go.mod h1:aADbAvTzZrKGd4Td7d1l4Dp5Hx7lLJEvVH7guIHoDf8= |
|||
golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= |
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= |
|||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= |
|||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= |
|||
golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= |
|||
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM= |
|||
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= |
|||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= |
|||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= |
|||
golang.org/x/net v0.0.0-20200930145003-4acb6c075d10 h1:YfxMZzv3PjGonQYNUaeU2+DhAdqOxerQ30JFB6WgAXo= |
|||
golang.org/x/net v0.0.0-20200930145003-4acb6c075d10/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= |
|||
golang.org/x/sys v0.0.0-20180606202747-9527bec2660b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
|||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
|||
golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
|||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
|||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
|||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/sys v0.0.0-20200929083018-4d22bbb62b3c h1:/h0vtH0PyU0xAoZJVcRw1k0Ng+U0JAy3QDiFmppIlIE= |
|||
golang.org/x/sys v0.0.0-20200929083018-4d22bbb62b3c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= |
|||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |
|||
gopkg.in/AlecAivazis/survey.v1 v1.8.4/go.mod h1:iBNOmqKz/NUbZx3bA+4hAGLRC7fSK7tgtVDT4tB22XA= |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|||
gopkg.in/yaml.v3 v3.0.0-20190709130402-674ba3eaed22/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
|||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= |
|||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
@ -0,0 +1,37 @@ |
|||
package main |
|||
|
|||
import ( |
|||
_ "embed" |
|||
"optimus/backend/config" |
|||
"optimus/backend/image" |
|||
"optimus/backend/stat" |
|||
|
|||
"github.com/wailsapp/wails" |
|||
) |
|||
|
|||
//go:embed frontend/dist/app.js
|
|||
var js string |
|||
|
|||
//go:embed frontend/dist/app.css
|
|||
var css string |
|||
|
|||
func main() { |
|||
app := wails.CreateApp(&wails.AppConfig{ |
|||
Width: 1200, |
|||
Height: 742, |
|||
Title: "Optimus", |
|||
JS: js, |
|||
CSS: css, |
|||
Colour: "#18181f", |
|||
Resizable: true, |
|||
}) |
|||
|
|||
cfg := config.NewConfig() |
|||
st := stat.NewStat() |
|||
fm := image.NewFileManager(cfg, st) |
|||
|
|||
app.Bind(cfg) |
|||
app.Bind(st) |
|||
app.Bind(fm) |
|||
_ = app.Run() |
|||
} |
@ -0,0 +1,6 @@ |
|||
[Desktop Entry] |
|||
Name=Optimus |
|||
Exec=optimus |
|||
Icon=appicon--512 |
|||
Type=Application |
|||
Categories=GTK;GNOME;Utility;Development; |
@ -0,0 +1,10 @@ |
|||
{ |
|||
"bin": "optimus.exe", |
|||
"description": "Optimus is an image conversion and optimization application. It supports conversion and compression between WebP, JPEG, and PNG image formats.", |
|||
"hash": "9e90cf09e659f7ecf5144d05d00fbb7315739dabd2dea14bb6294c5d26b3e761", |
|||
"homepage": "https://github.com/splode/optimus", |
|||
"license": "MIT", |
|||
"shortcuts": [["optimus.exe", "Optimus"]], |
|||
"url": "https://github.com/Splode/optimus/releases/download/v0.5.0-beta/optimus_v0.5.0-beta_windows_x86_64.zip", |
|||
"version": "0.5.0-beta" |
|||
} |
@ -0,0 +1,22 @@ |
|||
{ |
|||
"name": "Optimus", |
|||
"description": "Image conversion and compression. Supports WebP, JPEG, and PNG.", |
|||
"author": { |
|||
"name": "Christopher Murphy", |
|||
"email": "flyweight@protonmail.com" |
|||
}, |
|||
"version": "0.5.0-beta", |
|||
"binaryname": "optimus", |
|||
"frontend": { |
|||
"dir": "frontend", |
|||
"install": "npm install", |
|||
"build": "npm run build", |
|||
"bridge": "src", |
|||
"serve": "npm run serve" |
|||
}, |
|||
"WailsVersion": "1.7.1", |
|||
"CrossCompile": false, |
|||
"Platform": "", |
|||
"Architecture": "", |
|||
"LdFlags": "" |
|||
} |
@ -0,0 +1,4 @@ |
|||
{ |
|||
"email": "flyweight@pm.me", |
|||
"name": "Christopher Murphy" |
|||
} |
@ -1,2 +0,0 @@ |
|||
*.css linguist-vendored |
|||
*.scss linguist-vendored |
@ -1,9 +0,0 @@ |
|||
mollywallet |
|||
mollywallet.exe |
|||
nohup.out |
|||
mollywallet-res.syso |
|||
mollywallet.exe.manifest |
|||
mollywallet.rc |
|||
build |
|||
.DS_Store |
|||
/.idea/ |
@ -1,67 +0,0 @@ |
|||
bold := $(shell tput bold) |
|||
sgr0 := $(shell tput sgr0) |
|||
|
|||
help: |
|||
@printf "\nBelow commands have been made available to ease up Molly Wallet development.\n\n" |
|||
|
|||
@printf "Molly Wallet App: \n" |
|||
|
|||
@printf " $(bold)run_dev_env$(sgr0) - Starts up the dev enviornment on http://localhost:8080\n" |
|||
@printf " $(bold)build_app$(sgr0) - Compiles the Molly Wallet to the build folder\n" |
|||
@printf " $(bold)buid_app_production$(sgr0) - Builds a production build for the OS it's running on\n" |
|||
@printf " $(bold)cross_compile_app$(sgr0) - Compiling for every OS and Platform to the build directory\n\n" |
|||
|
|||
@printf "Update Module: \n" |
|||
|
|||
@printf " $(bold)build_update_module$(sgr0) - Builds the update module to the ~/.dag folder\n" |
|||
@printf " $(bold)run_update_module$(sgr0) - Builds and runs the update module\n" |
|||
@printf " $(bold)cross_compile_update_module$(sgr0) - Compiling for every OS and Platform to the build directory\n\n" |
|||
|
|||
@printf "General: \n" |
|||
|
|||
@printf " $(bold)all$(sgr0) - Builds and compiles both the wallet and the Update Module\n" |
|||
@printf " $(bold)cross_compile_all$(sgr0) - Builds and compiles both the wallet and the Update Module for all Platforms\n\n" |
|||
|
|||
@printf " $(bold)clean$(sgr0) - Cleans up old builds from the build directory\n\n" |
|||
|
|||
|
|||
run_dev_env: |
|||
@echo "Starting up frontend dev env on http://localhost:8080..." |
|||
$(shell wails serve) \
|
|||
$(shell cd frontend && npm run serve) |
|||
|
|||
build_app: |
|||
@echo "Building Molly Wallet binary to build folder..." |
|||
wails build |
|||
|
|||
buid_app_production: |
|||
@echo "Building app for production..." |
|||
wails build -f -p |
|||
|
|||
cross_compile_app: |
|||
@echo "Compiling for every OS and Platform..." |
|||
wails build -x darwin/amd64 |
|||
wails build -x windows/amd64 |
|||
wails build -x linux/amd64 |
|||
|
|||
build_update_module: |
|||
@echo "Building update module binary to ~/.dag" |
|||
go build -o ~/.dag/update backend/cmd/update/main.go |
|||
|
|||
run_update_module: |
|||
@echo "Compiling and running the update module" |
|||
go run backend/cmd/update/main.go |
|||
|
|||
|
|||
cross_compile_update_module: |
|||
@echo "Compiling for every OS and Platform..." |
|||
GOOS=linux GOARCH=amd64 go build -o build/update-linux-amd backend/cmd/update/main.go |
|||
GOOS=windows GOARCH=amd64 go build -o build/update-windows-amd64 backend/cmd/update/main.go |
|||
GOOS=darwin GOARCH=amd64 go build -o build/main-darwin-amd64 backend/cmd/update/main.go |
|||
|
|||
clean: |
|||
@echo "Cleaning up build directory..." |
|||
@rm -rf build |
|||
|
|||
all: build_app build_update_module |
|||
cross_compile_all: cross_compile_app cross_compile_update_module |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 10 KiB |
@ -1,65 +0,0 @@ |
|||
package api |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net" |
|||
"net/http" |
|||
"net/rpc" |
|||
"os" |
|||
) |
|||
|
|||
type RPCEndpoints int |
|||
|
|||
type Signal struct { |
|||
PID int |
|||
Msg string |
|||
} |
|||
|
|||
// InitRPCServer initializes the RPC server that listens to incoming LCM tasks
|
|||
// by the RPC clients
|
|||
func InitRPCServer() error { |
|||
endpoints := new(RPCEndpoints) |
|||
// Publish the receivers methods
|
|||
err := rpc.Register(endpoints) |
|||
if err != nil { |
|||
return fmt.Errorf("format of service Task isn't correct. Reason: %v", err) |
|||
} |
|||
// Register a HTTP handler
|
|||
rpc.HandleHTTP() |
|||
// Listen to TPC connections on port 36866
|
|||
listener, err := net.Listen("tcp", ":36866") |
|||
if err != nil { |
|||
return fmt.Errorf("Listen error: %v", err) |
|||
} |
|||
|
|||
errs := make(chan error) |
|||
|
|||
// Start accept incoming HTTP connections
|
|||
go func() { |
|||
err = http.Serve(listener, nil) |
|||
if err != nil { |
|||
errs <- fmt.Errorf("Error serving: %v", err) |
|||
return |
|||
} |
|||
}() |
|||
|
|||
select { |
|||
case err := <-errs: |
|||
if err != nil { |
|||
return err |
|||
} |
|||
default: |
|||
return nil |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (rpc *RPCEndpoints) ShutDown(sig Signal, response *Signal) error { |
|||
|
|||
pid := os.Getpid() |
|||
*response = Signal{pid, "Shutting down application"} |
|||
|
|||
// time.Sleep(3 * time.Second)
|
|||
return nil |
|||
} |
@ -1,237 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"io" |
|||
"os" |
|||
"os/user" |
|||
|
|||
"github.com/jinzhu/gorm" |
|||
"github.com/sirupsen/logrus" |
|||
log "github.com/sirupsen/logrus" |
|||
"github.com/wailsapp/wails" |
|||
|
|||
"github.com/grvlle/constellation_wallet/backend/api" |
|||
"github.com/grvlle/constellation_wallet/backend/models" |
|||
) |
|||
|
|||
// WalletApplication holds all application specific objects
|
|||
// such as the Client/Server event bus and logger
|
|||
type WalletApplication struct { |
|||
Version string |
|||
RT *wails.Runtime |
|||
log *logrus.Logger |
|||
wallet models.Wallet |
|||
DB *gorm.DB |
|||
killSignal chan struct{} |
|||
Network struct { |
|||
URL string |
|||
Handles struct { |
|||
Send string // Takes TX Object, returns TX Hash (200)
|
|||
Transaction string // Takes TX Object, returns TX Hash (200)
|
|||
Balance string // Polls the wallets available balance
|
|||
} |
|||
BlockExplorer struct { |
|||
URL string |
|||
Handles struct { |
|||
Transactions string // Takes TX Hash, returns TX info
|
|||
Checkpoints string // Takes Checkpoint block hash, returns checkpoint block info
|
|||
Snapshots string // Takes SnapshotHash, returns info
|
|||
CollectTX string // Takes DAG address, returns tx objects
|
|||
|
|||
} |
|||
} |
|||
} |
|||
paths struct { |
|||
HomeDir string |
|||
DAGDir string |
|||
TMPDir string |
|||
EncryptedDir string |
|||
EmptyTXFile string |
|||
PrevTXFile string |
|||
LastTXFile string |
|||
AddressFile string |
|||
ImageDir string |
|||
Java string |
|||
} |
|||
KeyStoreAccess bool |
|||
TransactionFinished bool |
|||
TransactionFailed bool |
|||
UserLoggedIn bool |
|||
NewUser bool |
|||
WalletImported bool |
|||
FirstTX bool |
|||
SecondTX bool |
|||
WidgetRunning struct { |
|||
PassKeysToFrontend bool |
|||
DashboardWidgets bool |
|||
} |
|||
WalletCLI struct { |
|||
URL string |
|||
Version string |
|||
} |
|||
} |
|||
|
|||
// WailsShutdown is called when the application is closed
|
|||
func (a *WalletApplication) WailsShutdown() { |
|||
a.wallet = models.Wallet{} |
|||
close(a.killSignal) // Kills the Go Routines
|
|||
a.DB.Close() |
|||
} |
|||
|
|||
// WailsInit initializes the Client and Server side bindings
|
|||
func (a *WalletApplication) WailsInit(runtime *wails.Runtime) error { |
|||
var err error |
|||
|
|||
a.log = logrus.New() |
|||
err = a.initDirectoryStructure() |
|||
if err != nil { |
|||
a.log.Errorln("Unable to set up directory structure. Reason: ", err) |
|||
} |
|||
|
|||
a.initLogger() |
|||
|
|||
err = api.InitRPCServer() |
|||
if err != nil { |
|||
a.log.Panicf("Unable to initialize RPC Server. Reason: %v", err) |
|||
} |
|||
a.log.Infoln("RPC Server initialized.") |
|||
|
|||
a.UserLoggedIn = false |
|||
a.NewUser = false |
|||
a.TransactionFinished = true |
|||
a.RT = runtime |
|||
a.killSignal = make(chan struct{}) // Used to kill go routines and hand back system resources
|
|||
a.wallet.Currency = "USD" // Set default currency
|
|||
a.WalletCLI.URL = "https://github.com/Constellation-Labs/constellation/releases/download" |
|||
a.WalletCLI.Version = "2.6.0" |
|||
a.Version = "1.2.0" |
|||
|
|||
a.DB, err = gorm.Open("sqlite3", a.paths.DAGDir+"/store.db") |
|||
if err != nil { |
|||
a.log.Panicln("failed to connect database", err) |
|||
} |
|||
// Migrate the schema
|
|||
a.DB.AutoMigrate(&models.Wallet{}, &models.TXHistory{}, &models.Path{}) |
|||
a.detectJavaPath() |
|||
a.initMainnetConnection() |
|||
a.newReleaseAvailable() |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// initLogger writes logs to STDOUT and a.paths.DAGDir/wallet.log
|
|||
func (a *WalletApplication) initLogger() { |
|||
logFile, err := os.OpenFile(a.paths.DAGDir+"/wallet.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0664) |
|||
if err != nil { |
|||
a.log.Fatal("Unable to create log file.") |
|||
} |
|||
mw := io.MultiWriter(os.Stdout, logFile) |
|||
a.log.SetOutput(mw) |
|||
a.log.SetFormatter(&log.TextFormatter{ |
|||
ForceColors: true, |
|||
FullTimestamp: true, |
|||
}) |
|||
} |
|||
|
|||
// Initializes the Directory Structure and stores the paths to the WalletApplication struct.
|
|||
func (a *WalletApplication) initDirectoryStructure() error { |
|||
|
|||
user, err := user.Current() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
a.paths.HomeDir = user.HomeDir // Home directory of the user
|
|||
a.paths.DAGDir = a.paths.HomeDir + "/.dag" // DAG directory for configuration files and wallet specific data
|
|||
a.paths.TMPDir = a.paths.DAGDir + "/tmp" |
|||
a.paths.LastTXFile = a.paths.TMPDir + "/last_tx" |
|||
a.paths.PrevTXFile = a.paths.TMPDir + "/prev_tx" |
|||
a.paths.EmptyTXFile = a.paths.TMPDir + "/genesis_tx" |
|||
a.paths.ImageDir = "./frontend/src/assets/img/" // Image Folder
|
|||
|
|||
a.log.Info("DAG Directory: ", a.paths.DAGDir) |
|||
|
|||
err = a.directoryCreator(a.paths.DAGDir, a.paths.TMPDir) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// initMainnetConnection populates the WalletApplication struct with mainnet data
|
|||
func (a *WalletApplication) initMainnetConnection() { |
|||
a.Network.URL = "http://lb.constellationnetwork.io:9000" // Temp
|
|||
|
|||
a.Network.Handles.Send = "/send" |
|||
a.Network.Handles.Transaction = "/transaction" |
|||
a.Network.Handles.Balance = "/address/" |
|||
|
|||
a.Network.BlockExplorer.URL = "https://xju69fets2.execute-api.us-west-1.amazonaws.com/cl-block-explorer-mainnet" |
|||
a.Network.BlockExplorer.Handles.Transactions = "/transactions/" |
|||
a.Network.BlockExplorer.Handles.Checkpoints = "/checkpoints/" |
|||
a.Network.BlockExplorer.Handles.Snapshots = "/snapshots/" |
|||
a.Network.BlockExplorer.Handles.CollectTX = "/transactions?sender=" |
|||
} |
|||
|
|||
// APIError reported by the blockexplerer/loadbalancer are reported in the following format
|
|||
// {"error": "Cannot find transactions for sender"}
|
|||
type APIError struct { |
|||
Error string |
|||
} |
|||
|
|||
// verifyAPIResponse takes API response converted to a byte array and checks if the API returned
|
|||
// an error. If it did, it'll return the error message.
|
|||
func (a *WalletApplication) verifyAPIResponse(r []byte) (bool, string) { |
|||
APIErr := APIError{} |
|||
if string(r[3:8]) == "error" { |
|||
err := json.Unmarshal(r, &APIErr) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to parse API error. Reason: ", err) |
|||
} |
|||
return false, APIErr.Error |
|||
} |
|||
return true, "" |
|||
} |
|||
|
|||
func (a *WalletApplication) sendSuccess(msg string) { |
|||
|
|||
if len(msg) > 200 { |
|||
msg = string(msg[:200]) // Restrict error size for frontend
|
|||
a.RT.Events.Emit("success", msg) |
|||
return |
|||
} |
|||
a.RT.Events.Emit("success", msg) |
|||
} |
|||
|
|||
func (a *WalletApplication) sendWarning(msg string) { |
|||
|
|||
if len(msg) > 200 { |
|||
msg = string(msg[:200]) // Restrict error size for frontend
|
|||
a.RT.Events.Emit("warning", msg) |
|||
return |
|||
} |
|||
a.RT.Events.Emit("warning", msg) |
|||
} |
|||
|
|||
func (a *WalletApplication) sendError(msg string, err error) { |
|||
|
|||
var errStr string |
|||
|
|||
if err != nil { |
|||
b := []byte(err.Error()) |
|||
if len(b) > 80 { |
|||
errStr = string(b[:80]) // Restrict error size for frontend
|
|||
} else if len(b) < 80 { |
|||
errStr = string(b) |
|||
} else { |
|||
errStr = "" |
|||
} |
|||
|
|||
a.RT.Events.Emit("error_handling", msg, errStr+" ...") |
|||
} else { |
|||
a.RT.Events.Emit("error_handling", msg+" ...") |
|||
} |
|||
|
|||
} |
@ -1,210 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"bytes" |
|||
"errors" |
|||
"fmt" |
|||
"io/ioutil" |
|||
"os" |
|||
"os/exec" |
|||
"runtime" |
|||
"strconv" |
|||
"time" |
|||
) |
|||
|
|||
/* AssemblyWrapper contains the code that is interacting with the wallet assembly provided |
|||
by the Constellation Engineering team. The code is interfacing with the wallet assembly using |
|||
the CLI */ |
|||
|
|||
func (a *WalletApplication) runWalletCMD(tool string, scalaFunc string, scalaArgs ...string) error { |
|||
var main string |
|||
|
|||
if runtime.GOOS == "windows" { |
|||
main = a.paths.Java |
|||
} else { |
|||
main = "java" |
|||
} |
|||
|
|||
cmds := []string{"-jar", a.paths.DAGDir + "/cl-" + tool + ".jar", scalaFunc} |
|||
args := append(cmds, scalaArgs...) |
|||
cmd := exec.Command(main, args...) |
|||
a.log.Infoln("Running command: ", cmd) |
|||
|
|||
var out bytes.Buffer |
|||
var stderr bytes.Buffer |
|||
cmd.Stdout = &out // Captures STDOUT
|
|||
cmd.Stderr = &stderr // Captures STDERR
|
|||
|
|||
err := cmd.Run() |
|||
if err != nil { |
|||
errFormatted := fmt.Sprint(err) + ": " + stderr.String() |
|||
return errors.New(errFormatted) |
|||
} |
|||
fmt.Println(out.String()) |
|||
|
|||
a.log.Debugln(cmd) |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// WalletKeystoreAccess is true if the user can unlock the .p12 keystore
|
|||
// and key using storepass and keypass
|
|||
func (a *WalletApplication) WalletKeystoreAccess() bool { |
|||
a.log.Infoln("Checking Keystore Access...") |
|||
|
|||
rescueStdout := os.Stdout |
|||
r, w, err := os.Pipe() |
|||
if err != nil { |
|||
a.log.Errorln("Unable to pipe STDOUT, Reason: ", err) |
|||
a.sendError("Unable to pipe STDOUT, Reason: ", err) |
|||
} |
|||
os.Stdout = w |
|||
err = a.runWalletCMD("wallet", "show-address", "--keystore="+a.wallet.KeyStorePath, "--alias="+a.wallet.WalletAlias, "--env_args=true") |
|||
if err != nil { |
|||
a.log.Warn("KeyStore Access Rejected!") |
|||
a.LoginError("Access Denied. Please make sure that you have typed in the correct credentials.") |
|||
a.KeyStoreAccess = false |
|||
return a.KeyStoreAccess |
|||
} |
|||
|
|||
// STDOUT is captured here
|
|||
|
|||
w.Close() |
|||
dagAddress, err := ioutil.ReadAll(r) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to read address from STDOUT", err) |
|||
a.sendError("Unable to read address from STDOUT", err) |
|||
} |
|||
// if STDOUT prefix of show-address output isn't DAG
|
|||
|
|||
if err == nil && a.wallet.Address != "" && string(dagAddress[:40]) == a.wallet.Address { |
|||
a.KeyStoreAccess = true |
|||
a.log.Info("KeyStore Access Granted!") |
|||
return a.KeyStoreAccess |
|||
} |
|||
os.Stdout = rescueStdout |
|||
|
|||
a.KeyStoreAccess = false |
|||
a.log.Warn("KeyStore Access Rejected!") |
|||
a.LoginError("Access Denied. Please make sure that you have typed in the correct credentials.") |
|||
return a.KeyStoreAccess |
|||
} |
|||
|
|||
// GenerateDAGAddress generates a new wallet address and stores it in memory
|
|||
// java -cp constellation-assembly-1.0.12.jar org.constellation.util.wallet.GenerateAddress --pub_key_str=<base64 hash of pubkey> --store_path=<path to file where address will be stored>
|
|||
func (a *WalletApplication) GenerateDAGAddress() string { |
|||
a.log.Infoln("Creating DAG Address from Public Key...") |
|||
|
|||
rescueStdout := os.Stdout |
|||
r, w, err := os.Pipe() |
|||
if err != nil { |
|||
a.log.Errorln("Unable to pipe STDOUT, Reason: ", err) |
|||
a.sendError("Unable to pipe STDOUT, Reason: ", err) |
|||
return "" |
|||
} |
|||
os.Stdout = w |
|||
|
|||
err = a.runWalletCMD("wallet", "show-address", "--keystore="+a.wallet.KeyStorePath, "--alias="+a.wallet.WalletAlias, "--env_args=true") |
|||
if err != nil { |
|||
a.sendError("Unable to generate wallet address. Reason:", err) |
|||
a.log.Errorf("Unable to generate wallet address. Reason: %s", err.Error()) |
|||
return "" |
|||
} |
|||
|
|||
w.Close() |
|||
dagAddress, err := ioutil.ReadAll(r) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to read address from STDOUT", err) |
|||
a.sendError("Unable to read address from STDOUT", err) |
|||
} |
|||
os.Stdout = rescueStdout |
|||
a.wallet.Address = string(dagAddress[:40]) |
|||
|
|||
return a.wallet.Address |
|||
} |
|||
|
|||
// CheckAndFetchWalletCLI will download the cl-wallet dependencies from
|
|||
// the official Constellation Repo
|
|||
func (a *WalletApplication) CheckAndFetchWalletCLI() bool { |
|||
keytoolPath := a.paths.DAGDir + "/cl-keytool.jar" |
|||
walletPath := a.paths.DAGDir + "/cl-wallet.jar" |
|||
|
|||
keytoolExists := a.fileExists(keytoolPath) |
|||
walletExists := a.fileExists(walletPath) |
|||
|
|||
if keytoolExists && walletExists { |
|||
return true |
|||
} |
|||
|
|||
if keytoolExists { |
|||
a.log.Info(keytoolPath + " file exists. Skipping downloading") |
|||
} else { |
|||
if err := a.fetchWalletJar("cl-keytool.jar", keytoolPath); err != nil { |
|||
a.log.Errorln("Unable to fetch or store cl-keytool.jar", err) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
if walletExists { |
|||
a.log.Info(walletPath + " file exists. Skipping downloading") |
|||
} else { |
|||
if err := a.fetchWalletJar("cl-wallet.jar", walletPath); err != nil { |
|||
a.log.Errorln("Unable to fetch or store cl-wallet.jar", err) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
if a.fileExists(keytoolPath) && a.fileExists(walletPath) { |
|||
return true |
|||
} else { |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// produceTXObject will put an actual transaction on the network. This is called from the
|
|||
// transactions.go file, more specifically the sendTransaction func. This in turn is triggered
|
|||
// from the frontend (Transactions.vue) and the tx func. note you can either pass a priv key like
|
|||
// or pass in a path to an encrypted .p12 file
|
|||
|
|||
// java -jar cl-wallet.jar create-transaction --keystore testkey.p12 --alias alias --storepass storepass --keypass keypass -d DAG6o9dcxo2QXCuJS8wnrR944YhFBpwc2jsh5j8f -p prev_tx -f new_tx --fee 0 --amount 1
|
|||
func (a *WalletApplication) produceTXObject(amount int64, fee int64, address, newTX, prevTX string) { |
|||
|
|||
// Convert to string
|
|||
amountStr := strconv.FormatInt(amount, 10) |
|||
// feeStr := strconv.FormatInt(fee, 10)
|
|||
|
|||
// amountNorm, err := normalizeAmounts(amount)
|
|||
// if err != nil {
|
|||
// a.log.Errorln("Unable to normalize amounts when producing tx object. Reason: ", err)
|
|||
// a.sendError("Unable to send transaction. Don't worry, your funds are safe. Please report this issue. Reason: ", err)
|
|||
// return
|
|||
// }
|
|||
feeNorm, err := normalizeAmounts(fee) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to normalize amounts when producing tx object. Reason: ", err) |
|||
a.sendError("Unable to send transaction. Don't worry, your funds are safe. Please report this issue. Reason: ", err) |
|||
return |
|||
} |
|||
|
|||
// newTX is the full command to sign a new transaction
|
|||
err = a.runWalletCMD("wallet", "create-transaction", "--keystore="+a.wallet.KeyStorePath, "--normalized", "--alias="+a.wallet.WalletAlias, "--amount="+amountStr, "--fee="+feeNorm, "-d="+address, "-f="+newTX, "-p="+prevTX, "--env_args=true") |
|||
if err != nil { |
|||
a.sendError("Unable to send transaction. Don't worry, your funds are safe. Please report this issue. Reason: ", err) |
|||
a.log.Errorln("Unable to send transaction. Reason: ", err) |
|||
return |
|||
} |
|||
time.Sleep(1 * time.Second) // Will sleep for 1 sec between TXs to prevent spamming.
|
|||
} |
|||
|
|||
// CreateEncryptedKeyStore is called ONLY when a NEW wallet is created. This
|
|||
// will create a new password protected encrypted keypair stored in user selected location
|
|||
// java -jar cl-keytool.jar --keystore testkey.p12 --alias alias --storepass storepass --keypass keypass
|
|||
func (a *WalletApplication) CreateEncryptedKeyStore() error { |
|||
err := a.runWalletCMD("keytool", "--keystore="+a.wallet.KeyStorePath, "--alias="+a.wallet.WalletAlias, "--env_args=true") |
|||
if err != nil { |
|||
a.LoginError("Unable to write encrypted keys to filesystem.") |
|||
a.log.Errorf("Unable to write encrypted keys to filesystem. Reason: %s", err.Error()) |
|||
return err |
|||
} |
|||
return nil |
|||
} |
@ -1,252 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"io/ioutil" |
|||
"net/http" |
|||
|
|||
"github.com/grvlle/constellation_wallet/backend/models" |
|||
) |
|||
|
|||
// formTXChain retains the order of the blockchain across all accounts.
|
|||
// Also calls the methods to create block objects (write json to file),
|
|||
// and the method that pushes them to the network(HTTP POST).
|
|||
// To retain order, formTXChain will poll the last sent TX for it's Failed state.
|
|||
// if the last TX failed, it'll switch up the order to account for that not to break the chain.
|
|||
// This means that all failed attempts at creating a block is also stored in the DB with
|
|||
// a Failed state.
|
|||
func (a *WalletApplication) formTXChain(amount int64, fee int64, address string, ptxObj *Transaction, ltxObj *Transaction) { |
|||
|
|||
statusLastTX := models.TXHistory{} |
|||
if err := a.DB.Last(&statusLastTX).Error; err != nil { |
|||
a.log.Warnln("No previous TX detected for this wallet. Reason: ", err) |
|||
} |
|||
|
|||
if statusLastTX.Failed { |
|||
a.log.Warnln("Previous Transaction has a failed state. Adapting...", statusLastTX.Failed) |
|||
} |
|||
// Queries the number of previous transactions for this wallet.
|
|||
numberOfTX := a.DB.Model(&a.wallet).Association("TXHistory").Count() |
|||
|
|||
// First TX does not contain a TXref
|
|||
if numberOfTX == 0 { |
|||
a.log.Infoln("Detected that this is the first TX sent from this key.") |
|||
a.produceTXObject(amount, fee, address, a.paths.LastTXFile, a.paths.EmptyTXFile) |
|||
a.sendTransaction(a.paths.LastTXFile) |
|||
return |
|||
} |
|||
|
|||
a.log.Infoln("Number of previous TX's detected: ", numberOfTX) |
|||
|
|||
// Manually control the second TX, to ensure the following order
|
|||
if numberOfTX == 1 { |
|||
|
|||
// If the first transaction failed, enforce the order.
|
|||
if statusLastTX.Failed { |
|||
a.log.Warnln("Detected that the previous transaction failed.") |
|||
a.produceTXObject(amount, fee, address, a.paths.LastTXFile, a.paths.EmptyTXFile) |
|||
a.sendTransaction(a.paths.LastTXFile) |
|||
return |
|||
} |
|||
|
|||
// Check for edge case where PrevTXFile has already been written and needs to be referenced.
|
|||
// This occurs when a wallet with 1 previous tx has been imported.
|
|||
prevTXFileContents := a.loadTXFromFile(a.paths.PrevTXFile) |
|||
if a.WalletImported && prevTXFileContents != "" { |
|||
a.log.Warnln("One previous transaction has been imported. Using that as reference.") |
|||
a.produceTXObject(amount, fee, address, a.paths.LastTXFile, a.paths.PrevTXFile) |
|||
a.sendTransaction(a.paths.LastTXFile) |
|||
return |
|||
} |
|||
|
|||
a.produceTXObject(amount, fee, address, a.paths.PrevTXFile, a.paths.LastTXFile) |
|||
a.sendTransaction(a.paths.PrevTXFile) |
|||
return |
|||
} |
|||
|
|||
// Returns the TX object that has the highest ordinal (the highest determines if it's to be referenced or reference the other tx)
|
|||
newTX := a.determineBlockOrder(ptxObj, ltxObj) |
|||
|
|||
// If the last TX is in failed state, we reset the order.
|
|||
if newTX == a.paths.PrevTXFile && statusLastTX.Failed { |
|||
a.produceTXObject(amount, fee, address, a.paths.LastTXFile, a.paths.PrevTXFile) |
|||
a.sendTransaction(a.paths.LastTXFile) |
|||
return |
|||
} |
|||
|
|||
if newTX != a.paths.PrevTXFile && !statusLastTX.Failed { |
|||
a.produceTXObject(amount, fee, address, a.paths.LastTXFile, a.paths.PrevTXFile) |
|||
a.sendTransaction(a.paths.LastTXFile) |
|||
return |
|||
} |
|||
a.produceTXObject(amount, fee, address, a.paths.PrevTXFile, a.paths.LastTXFile) |
|||
a.sendTransaction(a.paths.PrevTXFile) |
|||
} |
|||
|
|||
func (a *WalletApplication) determineBlockOrder(ptxObj, ltxObj *Transaction) string { |
|||
// The higher ordinal will always be the TX carrying the TX Ref.
|
|||
a.log.Info("Last TX Ordinal: ", ltxObj.LastTxRef.Ordinal, " Previous TX Ordinal: ", ptxObj.LastTxRef.Ordinal) |
|||
if ltxObj.LastTxRef.Ordinal > ptxObj.LastTxRef.Ordinal { |
|||
return a.paths.PrevTXFile |
|||
} |
|||
return a.paths.LastTXFile |
|||
|
|||
} |
|||
|
|||
// convertToTXObject takes the Path to the prev_tx and last_tx files and returns a
|
|||
// pointer to two workable objects.
|
|||
func (a *WalletApplication) convertToTXObject(ptx, ltx string) (*Transaction, *Transaction) { |
|||
var ptxObj Transaction |
|||
var ltxObj Transaction |
|||
|
|||
rbytes := []byte(ptx) |
|||
lbytes := []byte(ltx) |
|||
|
|||
err := json.Unmarshal(rbytes, &ptxObj) |
|||
if err != nil { |
|||
a.log.Warnln("TX Object: ", string(rbytes), err) |
|||
} |
|||
err = json.Unmarshal(lbytes, <xObj) |
|||
if err != nil { |
|||
a.log.Warnln("TX Object: ", string(rbytes), err) |
|||
} |
|||
return &ptxObj, <xObj |
|||
} |
|||
|
|||
/* Called upon wallet import */ |
|||
|
|||
// TXReference is used to parse the previous tx of an imported wallet.
|
|||
type TXReference struct { |
|||
Hash string `json:"hash"` |
|||
Amount int64 `json:"amount"` |
|||
Receiver string `json:"receiver"` |
|||
Sender string `json:"sender"` |
|||
Fee int `json:"fee"` |
|||
IsDummy bool `json:"isDummy"` |
|||
LastTransactionRef struct { |
|||
PrevHash string `json:"prevHash"` |
|||
Ordinal int `json:"ordinal"` |
|||
} `json:"lastTransactionRef"` |
|||
SnapshotHash string `json:"snapshotHash"` |
|||
CheckpointBlock string `json:"checkpointBlock"` |
|||
TransactionOriginal struct { |
|||
Edge struct { |
|||
ObservationEdge struct { |
|||
Parents []struct { |
|||
HashReference string `json:"hashReference"` |
|||
HashType string `json:"hashType"` |
|||
BaseHash string `json:"baseHash"` |
|||
} `json:"parents"` |
|||
Data struct { |
|||
HashReference string `json:"hashReference"` |
|||
HashType string `json:"hashType"` |
|||
BaseHash string `json:"baseHash"` |
|||
} `json:"data"` |
|||
} `json:"observationEdge"` |
|||
SignedObservationEdge struct { |
|||
SignatureBatch struct { |
|||
Hash string `json:"hash"` |
|||
Signatures []struct { |
|||
Signature string `json:"signature"` |
|||
ID struct { |
|||
Hex string `json:"hex"` |
|||
} `json:"id"` |
|||
} `json:"signatures"` |
|||
} `json:"signatureBatch"` |
|||
} `json:"signedObservationEdge"` |
|||
Data struct { |
|||
Amount int64 `json:"amount"` |
|||
LastTxRef struct { |
|||
PrevHash string `json:"prevHash"` |
|||
Ordinal int `json:"ordinal"` |
|||
} `json:"lastTxRef"` |
|||
Fee interface{} `json:"fee"` |
|||
Salt int64 `json:"salt"` |
|||
} `json:"data"` |
|||
} `json:"edge"` |
|||
LastTxRef struct { |
|||
PrevHash string `json:"prevHash"` |
|||
Ordinal int `json:"ordinal"` |
|||
} `json:"lastTxRef"` |
|||
IsDummy bool `json:"isDummy"` |
|||
IsTest bool `json:"isTest"` |
|||
} `json:"transactionOriginal"` |
|||
} |
|||
|
|||
// rebuildTxChainState will query the blockexplorer for a transacion and write it to a.paths.PrevTXFile.
|
|||
// This will allow an imported wallet to reference the last transaction sent.
|
|||
func (a *WalletApplication) rebuildTxChainState(lastTXHash string) error { |
|||
a.log.Info("Sending API call to block explorer on: " + a.Network.BlockExplorer.Handles.Transactions) |
|||
|
|||
resp, err := http.Get(a.Network.BlockExplorer.URL + a.Network.BlockExplorer.Handles.Transactions + lastTXHash) |
|||
if err != nil { |
|||
a.log.Errorln("Failed to send HTTP request. Reason: ", err) |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Delete(&a.wallet).Error; err != nil { |
|||
a.log.Errorln("Unable to delete wallet upon failed import. Reason: ", err) |
|||
return err |
|||
} |
|||
a.LoginError("Unable to collect previous TX's from blockexplorer. Please check your internet connection.") |
|||
return err |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.Body != nil { |
|||
bodyBytes, err := ioutil.ReadAll(resp.Body) |
|||
if err != nil { |
|||
a.LoginError("Unable to collect previous TX's from blockexplorer. Please try again later.") |
|||
a.log.Errorln("Unable to collect previous TX's from blockexplorer. Reason: ", err) |
|||
} |
|||
ok, error := a.verifyAPIResponse(bodyBytes) |
|||
// Blockexplorer returns below string when no previous transactions are found
|
|||
if !ok && error != "Cannot find transaction" { |
|||
a.log.Errorln("API returned the following error", error) |
|||
// If unable to import last transaction, remove wallet from DB and logout.
|
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Delete(&a.wallet).Error; err != nil { |
|||
a.log.Errorln("Unable to delete wallet upon failed import. Reason: ", err) |
|||
return err |
|||
} |
|||
a.log.Panicln("Unable to import previous transactions") // TODO: logout user from wallet
|
|||
a.LoginError("The wallet import failed. Please check your internet connection and try again.") |
|||
return err |
|||
} |
|||
|
|||
// Parsing JSON object to TXReference ->
|
|||
lastTX := TXReference{} |
|||
err = json.Unmarshal(bodyBytes, &lastTX) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to fetch TX history from block explorer. Reason: ", err) |
|||
a.sendError("Unable to fetch TX history from block explorer. Reason: ", err) |
|||
return err |
|||
} |
|||
// Marshal so that we can unmarshat into tx object ->
|
|||
b, err := json.Marshal(lastTX.TransactionOriginal) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to parse last transaction hash. Reason: ", err) |
|||
a.sendError("Unable to fetch TX history from block explorer. Reason: ", err) |
|||
return err |
|||
} |
|||
|
|||
// Populating tx object ->
|
|||
tx := Transaction{} |
|||
err = json.Unmarshal(b, &tx) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to parse last transaction hash. Reason: ", err) |
|||
a.sendError("Unable to fetch TX history from block explorer. Reason: ", err) |
|||
return err |
|||
} |
|||
|
|||
// Converting to json
|
|||
txBytes, err := json.Marshal(tx) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to parse last transaction hash. Reason: ", err) |
|||
a.sendError("Unable to fetch TX history from block explorer. Reason: ", err) |
|||
return err |
|||
} |
|||
|
|||
err = WriteToFile(a.paths.PrevTXFile, txBytes) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
return nil |
|||
} |
@ -1,441 +0,0 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"crypto/sha256" |
|||
"encoding/hex" |
|||
"flag" |
|||
"fmt" |
|||
"io" |
|||
"io/ioutil" |
|||
"net/http" |
|||
"net/rpc" |
|||
"os" |
|||
"os/exec" |
|||
"path" |
|||
"runtime" |
|||
"strings" |
|||
"time" |
|||
|
|||
"github.com/artdarek/go-unzip" |
|||
log "github.com/sirupsen/logrus" |
|||
) |
|||
|
|||
func init() { |
|||
// initialize update.log file and set log output to file
|
|||
file, err := os.OpenFile(path.Join(getDefaultDagFolderPath(), "update.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) |
|||
if err == nil { |
|||
log.SetOutput(file) |
|||
} else { |
|||
log.Info("Failed to log to file, using default stderr") |
|||
} |
|||
|
|||
// Only log the warning severity or above.
|
|||
log.SetLevel(log.InfoLevel) |
|||
} |
|||
|
|||
// Update type contains the update processes mandatory data
|
|||
type Update struct { |
|||
clientRPC *rpc.Client |
|||
downloadURL string |
|||
dagFolderPath *string |
|||
oldMollyBinaryPath *string |
|||
newVersion *string |
|||
triggerUpdate *bool |
|||
} |
|||
|
|||
// Signal is used for IPC with MollyWallet
|
|||
type Signal struct { |
|||
PID int |
|||
Msg string |
|||
} |
|||
|
|||
type unzippedContents struct { |
|||
newMollyBinaryPath string |
|||
updateBinaryPath string |
|||
} |
|||
|
|||
func main() { |
|||
var update Update |
|||
|
|||
update.downloadURL = "https://github.com/grvlle/constellation_wallet/releases/download" |
|||
|
|||
// MollyWallet provides the below data when an update is triggered
|
|||
update.dagFolderPath = flag.String("init_dag_path", getDefaultDagFolderPath(), "Enter the directory path to dag folder") |
|||
update.oldMollyBinaryPath = flag.String("init_molly_path", "", "Enter the directory path where the molly binary is located") |
|||
update.newVersion = flag.String("new_version", "", "Enter the new semantic version. E.g 1.2.3") |
|||
update.triggerUpdate = flag.Bool("upgrade", false, "Upgrade molly wallet binary") |
|||
flag.Parse() |
|||
|
|||
update.Run() |
|||
|
|||
} |
|||
|
|||
// Run is the main method that runs the full update.
|
|||
func (u *Update) Run() { |
|||
var err error |
|||
|
|||
// Clean up old update artifacts
|
|||
err = u.CleanUp() |
|||
if err != nil { |
|||
log.Fatalf("Unable to clear previous local state: %v", err) |
|||
} |
|||
|
|||
// Create a TCP connection to localhost on port 36866
|
|||
u.clientRPC, err = rpc.DialHTTP("tcp", "localhost:36866") |
|||
if err != nil { |
|||
log.Fatal("Connection error: ", err) |
|||
} |
|||
log.Infof("Successfully established RPC connection with Molly Wallet") |
|||
defer u.clientRPC.Close() |
|||
|
|||
zippedArchive, err := u.DownloadAppBinary() |
|||
if err != nil { |
|||
log.Fatalf("Unable to download v%s of Molly Wallet: %v", *u.newVersion, err) |
|||
} |
|||
|
|||
ok, err := u.VerifyChecksum(zippedArchive) |
|||
if err != nil || !ok { |
|||
log.Fatalf("Checksum missmatch. Corrupted download: %v", err) |
|||
} |
|||
|
|||
contents, err := unzipArchive(zippedArchive, *u.dagFolderPath) |
|||
if err != nil { |
|||
log.Fatalf("Unable to unzip contents: %v", err) |
|||
} |
|||
|
|||
err = u.BackupApp() |
|||
if err != nil { |
|||
log.Fatalf("Unable to Backup Molly Wallet: %v", err) |
|||
} |
|||
|
|||
err = u.TerminateAppService() |
|||
if err != nil { |
|||
log.Errorf("Unable to terminate Molly Wallet: %v", err) |
|||
err = u.RestoreBackup() |
|||
if err != nil { |
|||
log.Fatal("Unable to restore backup: %v", err) |
|||
} |
|||
} |
|||
|
|||
err = u.ReplaceAppBinary(contents) |
|||
if err != nil { |
|||
log.Errorf("Unable to overwrite old installation: %v", err) |
|||
err = u.RestoreBackup() |
|||
if err != nil { |
|||
log.Fatalf("Unable to restore backup: %v", err) |
|||
} |
|||
} |
|||
|
|||
err = u.LaunchAppBinary() |
|||
if err != nil { |
|||
log.Errorf("Unable to start up Molly after update: %v", err) |
|||
err = u.RestoreBackup() |
|||
if err != nil { |
|||
log.Fatalf("Unable to restore backup: %v", err) |
|||
} |
|||
} |
|||
|
|||
err = u.CleanUp() |
|||
if err != nil { |
|||
log.Fatalf("Unable to clear previous local state: %v", err) |
|||
} |
|||
|
|||
} |
|||
|
|||
// DownloadAppBinary downloads the latest Molly Wallet zip from github releases and returns the path to it
|
|||
func (u *Update) DownloadAppBinary() (string, error) { |
|||
|
|||
filename := "mollywallet.zip" |
|||
osBuild, _ := getUserOS() // returns linux, windows, darwin or unsupported as well as the file extension (e.g ".exe")
|
|||
|
|||
if osBuild == "unsupported" { |
|||
return "", fmt.Errorf("the OS is not supported") |
|||
} |
|||
|
|||
url := u.downloadURL + "/v" + *u.newVersion + "-" + osBuild + "/" + filename |
|||
// e.g https://github.com/grvlle/constellation_wallet/releases/download/v1.1.9-linux/mollywallet.zip
|
|||
log.Infof("Constructed the following URL: %s", url) |
|||
|
|||
filePath := path.Join(*u.dagFolderPath, filename) |
|||
err := downloadFile(url, filePath) |
|||
if err != nil { |
|||
return "", fmt.Errorf("unable to download remote checksum: %v", err) |
|||
} |
|||
|
|||
return filePath, nil |
|||
} |
|||
|
|||
// VerifyChecksum takes a file path and will check the file sha256 checksum against the checksum included
|
|||
// in the downlaod returns false if there's a missmatch
|
|||
func (u *Update) VerifyChecksum(filePathZip string) (bool, error) { |
|||
// Download checksum
|
|||
filename := "checksum.sha256" |
|||
osBuild, _ := getUserOS() // returns linux, windows, darwin or unsupported as well as the file extension (e.g ".exe")
|
|||
|
|||
if osBuild == "unsupported" { |
|||
return false, fmt.Errorf("the OS is not supported") |
|||
} |
|||
|
|||
url := u.downloadURL + "/v" + *u.newVersion + "-" + osBuild + "/" + filename |
|||
// e.g https://github.com/grvlle/constellation_wallet/releases/download/v1.1.9-linux/checksum.sha256
|
|||
log.Infof("Constructed the following URL: %s", url) |
|||
|
|||
filePath := path.Join(*u.dagFolderPath, filename) |
|||
err := downloadFile(url, filePath) |
|||
if err != nil { |
|||
return false, fmt.Errorf("unable to download remote checksum: %v", err) |
|||
} |
|||
|
|||
// Read the contents of the downloaded file (remoteChecksum)
|
|||
content, err := ioutil.ReadFile(filePath) |
|||
if err != nil { |
|||
return false, err |
|||
} |
|||
lines := strings.Split(string(content), "\n") |
|||
remoteChecksum := lines[0] |
|||
log.Infof("Remote file checksum: %v", remoteChecksum) |
|||
|
|||
// Collect the checksum of the downloaded zip (localChecksum)
|
|||
f, err := os.Open(filePathZip) |
|||
if err != nil { |
|||
return false, err |
|||
} |
|||
defer f.Close() |
|||
|
|||
hasher := sha256.New() |
|||
if _, err := io.Copy(hasher, f); err != nil { |
|||
return false, err |
|||
} |
|||
localChecksum := hex.EncodeToString(hasher.Sum(nil)) |
|||
log.Infof("Local file checksum: %v", localChecksum) |
|||
|
|||
return remoteChecksum == localChecksum, nil |
|||
} |
|||
|
|||
// TerminateAppService will send an RPC to mollywallet to terminate the application
|
|||
func (u *Update) TerminateAppService() error { |
|||
sig := Signal{0, "Terminate Molly Wallet. Begining Update..."} |
|||
var response Signal |
|||
|
|||
err := u.clientRPC.Call("RPCEndpoints.ShutDown", sig, &response) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
proc, err := os.FindProcess(response.PID) |
|||
if err != nil { |
|||
log.Panicf("%v", err) |
|||
} |
|||
|
|||
err = proc.Kill() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// BackupApp backs up the old binary in case of a failed update.
|
|||
func (u *Update) BackupApp() error { |
|||
_, fileExt := getUserOS() |
|||
|
|||
// Create backup folder in ~/.dag
|
|||
err := os.Mkdir(*u.dagFolderPath+"/backup", 0755) |
|||
if err != nil { |
|||
return fmt.Errorf("unable to create backup folder: %v", err) |
|||
} |
|||
|
|||
// Copy the old Molly Wallet binary into ~/.dag/backup/
|
|||
err = copy(*u.oldMollyBinaryPath, *u.dagFolderPath+"/backup/mollywallet"+fileExt) |
|||
if err != nil { |
|||
return fmt.Errorf("unable to backup old Molly installation: %v", err) |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
// ReplaceAppBinary replaces the old update module and molly binary with the newly downloaded ones.
|
|||
func (u *Update) ReplaceAppBinary(contents *unzippedContents) error { |
|||
// Replace old molly binary with the new one
|
|||
_, fileExt := getUserOS() |
|||
err := copy(contents.newMollyBinaryPath, *u.oldMollyBinaryPath) |
|||
if err != nil { |
|||
for i := 5; i > 0; i-- { |
|||
time.Sleep(time.Duration(i) * time.Second) |
|||
err = copy(contents.newMollyBinaryPath, *u.oldMollyBinaryPath) |
|||
if err == nil { |
|||
break |
|||
} else if err != nil && i == 0 { |
|||
return fmt.Errorf("unable to overwrite old molly binary: %v", err) |
|||
} |
|||
} |
|||
} |
|||
// Replace old update binary with the new one
|
|||
if fileExists(contents.updateBinaryPath) { |
|||
err = copy(contents.updateBinaryPath, *u.dagFolderPath+"/update"+fileExt) |
|||
if err != nil { |
|||
return fmt.Errorf("unable to copy update binary to .dag folder: %v", err) |
|||
} |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// LaunchAppBinary executes the new molly binary
|
|||
func (u *Update) LaunchAppBinary() error { |
|||
cmd := exec.Command(*u.oldMollyBinaryPath) |
|||
err := cmd.Start() |
|||
if err != nil { |
|||
return fmt.Errorf("unable to execute run command for Molly Wallet: %v", err) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// RestoreBackup restores the backed up files
|
|||
func (u *Update) RestoreBackup() error { |
|||
|
|||
log.Infoln("Restoring Backup...") |
|||
|
|||
// Copy the old Molly Wallet binary from ~/.dag/backup/ to the old path
|
|||
_, fileExt := getUserOS() |
|||
err := copy(*u.dagFolderPath+"/backup/mollywallet"+fileExt, *u.oldMollyBinaryPath) |
|||
if err != nil { |
|||
return fmt.Errorf("unable to overwrite old molly binary: %v", err) |
|||
} |
|||
|
|||
// Copy update binary from ~/.dag/backup/update -> ~/.dag/update
|
|||
if fileExists(*u.dagFolderPath + "/backup/update" + fileExt) { |
|||
err = copy(*u.dagFolderPath+"/backup/update"+fileExt, *u.dagFolderPath+"/update"+fileExt) |
|||
if err != nil { |
|||
return fmt.Errorf("unable to copy update binary to .dag folder: %v", err) |
|||
} |
|||
} |
|||
|
|||
log.Infoln("Backup successfully restored.") |
|||
|
|||
return nil |
|||
|
|||
} |
|||
|
|||
// CleanUp removes uneccesary artifacts from the update process
|
|||
func (u *Update) CleanUp() error { |
|||
|
|||
if fileExists(*u.dagFolderPath + "/mollywallet.zip") { |
|||
err := os.Remove(*u.dagFolderPath + "/mollywallet.zip") |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
if fileExists(*u.dagFolderPath + "/backup") { |
|||
err := os.RemoveAll(*u.dagFolderPath + "/backup") |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
|
|||
if fileExists(*u.dagFolderPath + "/new_build") { |
|||
err := os.RemoveAll(*u.dagFolderPath + "/new_build") |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func downloadFile(url, filePath string) error { |
|||
|
|||
tmpFilePath := filePath + ".tmp" |
|||
out, err := os.Create(tmpFilePath) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
resp, err := http.Get(url) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if _, err = io.Copy(out, resp.Body); err != nil { |
|||
return err |
|||
} |
|||
|
|||
out.Close() // Close file to rename
|
|||
|
|||
if err = os.Rename(tmpFilePath, filePath); err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func getDefaultDagFolderPath() string { |
|||
userDir, err := os.UserHomeDir() |
|||
if err != nil { |
|||
log.Errorf("Unable to detect UserHomeDir: %v", err) |
|||
return "" |
|||
} |
|||
return userDir + "/.dag" |
|||
} |
|||
|
|||
func fileExists(path string) bool { |
|||
_, err := os.Stat(path) |
|||
if os.IsNotExist(err) { |
|||
return false |
|||
} |
|||
return !os.IsNotExist(err) |
|||
} |
|||
|
|||
func copy(src string, dst string) error { |
|||
// Read all content of src to data
|
|||
data, err := ioutil.ReadFile(src) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
// Write data to dst
|
|||
err = ioutil.WriteFile(dst, data, 0755) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// Unzips archive to dstPath, returns path to wallet binary
|
|||
func unzipArchive(zippedArchive, dstPath string) (*unzippedContents, error) { |
|||
|
|||
uz := unzip.New(zippedArchive, dstPath+"/"+"new_build/") |
|||
err := uz.Extract() |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
_, fileExt := getUserOS() |
|||
|
|||
contents := &unzippedContents{ |
|||
newMollyBinaryPath: dstPath + "/" + "new_build/mollywallet" + fileExt, |
|||
updateBinaryPath: dstPath + "/" + "new_build/update" + fileExt, |
|||
} |
|||
|
|||
return contents, err |
|||
} |
|||
|
|||
// getUserOS returns the users OS as well as the file extension of executables for said OS
|
|||
func getUserOS() (string, string) { |
|||
var osBuild string |
|||
var fileExt string |
|||
|
|||
switch os := runtime.GOOS; os { |
|||
case "darwin": |
|||
osBuild = "darwin" |
|||
fileExt = "" |
|||
case "linux": |
|||
osBuild = "linux" |
|||
fileExt = "" |
|||
case "windows": |
|||
osBuild = "windows" |
|||
fileExt = ".exe" |
|||
default: |
|||
osBuild = "unsupported" |
|||
fileExt = "" |
|||
} |
|||
|
|||
return osBuild, fileExt |
|||
} |
@ -1,393 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"fmt" |
|||
"io/ioutil" |
|||
"math/rand" |
|||
"net/http" |
|||
"strconv" |
|||
"time" |
|||
|
|||
"github.com/wailsapp/wails" |
|||
) |
|||
|
|||
const ( |
|||
dummyValue = 1000 |
|||
updateIntervalToken = 30 // Seconds
|
|||
updateIntervalCurrency = 50 // Seconds
|
|||
updateIntervalBlocks = 5 // Seconds
|
|||
updateIntervalPieChart = 60 // Seconds
|
|||
) |
|||
|
|||
// ChartData contains all the datapoints for the Charts
|
|||
// on the Dashboard.
|
|||
type ChartData struct { |
|||
NodesOnline struct { |
|||
Labels []string `json:"labels"` |
|||
Series []int `json:"series"` |
|||
} `json:"nodes_online"` |
|||
Transactions struct { |
|||
Labels []string `json:"labels"` |
|||
SeriesOne []int `json:"series_one"` |
|||
SeriesTwo []int `json:"series_two"` |
|||
} `json:"transactions"` |
|||
Throughput struct { |
|||
Labels []string `json:"labels"` |
|||
SeriesOne []int `json:"series_one"` |
|||
SeriesTwo []int `json:"series_two"` |
|||
} `json:"throughput"` |
|||
} |
|||
|
|||
// ChartDataInit initializes the ChartData struct with datapoints for
|
|||
// the charts in the wallet. These are stored on the fs as chart_data.json
|
|||
func (a *WalletApplication) ChartDataInit() *ChartData { |
|||
cd := &ChartData{} |
|||
|
|||
cd.NodesOnline.Labels = []string{"30%", "20%", "50%"} |
|||
cd.NodesOnline.Series = []int{30, 20, 50} |
|||
|
|||
cd.Transactions.Labels = []string{ |
|||
"Jan ", |
|||
"Feb ", |
|||
"Mar ", |
|||
"Apr ", |
|||
"Mai ", |
|||
"Jun ", |
|||
"Jul ", |
|||
"Aug ", |
|||
"Sep ", |
|||
"Oct ", |
|||
"Nov ", |
|||
"Dec "} |
|||
cd.Transactions.SeriesOne = []int{542, 543, 520, 680, 653, 753, 326, 434, 568, 610, 756, 895} |
|||
cd.Transactions.SeriesTwo = []int{230, 293, 380, 480, 503, 553, 600, 664, 698, 710, 736, 795} |
|||
|
|||
cd.Throughput.Labels = []string{ |
|||
"9AM", |
|||
"12AM", |
|||
"3PM", |
|||
"6PM", |
|||
"9PM", |
|||
"12PM", |
|||
"3AM", |
|||
"6AM"} |
|||
cd.Throughput.SeriesOne = []int{287, 385, 490, 562, 594, 626, 698, 895, 952} |
|||
cd.Throughput.SeriesTwo = []int{67, 152, 193, 240, 387, 435, 535, 642, 744} |
|||
|
|||
// Init chart widgets with data.
|
|||
go func() { |
|||
select { |
|||
case <-a.killSignal: |
|||
return |
|||
default: |
|||
for i := 0; i < 2; i++ { |
|||
a.RT.Events.Emit("tx_stats", cd.Transactions.SeriesOne, cd.Transactions.SeriesTwo, cd.Transactions.Labels) |
|||
a.RT.Events.Emit("node_stats", cd.NodesOnline.Series, cd.NodesOnline.Labels) |
|||
a.RT.Events.Emit("network_stats", cd.Throughput.SeriesOne, cd.Throughput.SeriesTwo, cd.Throughput.Labels) |
|||
time.Sleep(1 * time.Second) |
|||
} |
|||
} |
|||
}() |
|||
|
|||
return cd |
|||
} |
|||
|
|||
// Populates the Nodes Online pie chart with data from the block explorer.
|
|||
func (a *WalletApplication) nodeStats(cd *ChartData) { |
|||
go func() { |
|||
for { |
|||
select { |
|||
case <-a.killSignal: |
|||
return |
|||
default: |
|||
// Will populate the chart with random data
|
|||
for i := range cd.NodesOnline.Series { |
|||
cd.NodesOnline.Series[i] = rand.Intn(dummyValue) |
|||
} |
|||
a.RT.Events.Emit("node_stats", cd.NodesOnline.Series, cd.NodesOnline.Labels) |
|||
UpdateCounter(updateIntervalPieChart, "chart_counter", time.Second, a.RT) |
|||
time.Sleep(updateIntervalPieChart * time.Second) |
|||
} |
|||
} |
|||
}() |
|||
} |
|||
|
|||
func (a *WalletApplication) txStats(cd *ChartData) { |
|||
go func() { |
|||
for { |
|||
select { |
|||
case <-a.killSignal: |
|||
return |
|||
default: |
|||
// Will populate the chart with random data
|
|||
for i := range cd.Transactions.SeriesOne { |
|||
cd.Transactions.SeriesOne[i] = rand.Intn(dummyValue) |
|||
} |
|||
for i := range cd.Transactions.SeriesTwo { |
|||
cd.Transactions.SeriesTwo[i] = rand.Intn(dummyValue) |
|||
} |
|||
a.RT.Events.Emit("tx_stats", cd.Transactions.SeriesOne, cd.Transactions.SeriesTwo, cd.Transactions.Labels) |
|||
//UpdateCounter(updateIntervalPieChart, "chart_counter", time.Second, a.RT)
|
|||
time.Sleep(updateIntervalPieChart * time.Second) |
|||
} |
|||
} |
|||
}() |
|||
} |
|||
|
|||
func (a *WalletApplication) networkStats(cd *ChartData) { |
|||
|
|||
go func() { |
|||
for { |
|||
select { |
|||
case <-a.killSignal: |
|||
return |
|||
default: |
|||
// Will populate the chart with random data
|
|||
for i := range cd.Throughput.SeriesOne { |
|||
cd.Throughput.SeriesOne[i] = rand.Intn(dummyValue) |
|||
} |
|||
for i := range cd.Throughput.SeriesTwo { |
|||
cd.Throughput.SeriesTwo[i] = rand.Intn(dummyValue) |
|||
} |
|||
a.RT.Events.Emit("network_stats", cd.Throughput.SeriesOne, cd.Throughput.SeriesTwo, cd.Throughput.Labels) |
|||
//UpdateCounter(updateIntervalPieChart, "chart_counter", time.Second, a.RT)
|
|||
time.Sleep(updateIntervalPieChart * time.Second) |
|||
} |
|||
} |
|||
}() |
|||
} |
|||
|
|||
// BlockAmount is a temporary function
|
|||
func (a *WalletApplication) blockAmount() { |
|||
var randomNumber int |
|||
go func() { |
|||
for { |
|||
select { |
|||
case <-a.killSignal: |
|||
return |
|||
default: |
|||
randomNumber = rand.Intn(dummyValue) |
|||
a.RT.Events.Emit("blocks", randomNumber) |
|||
UpdateCounter(updateIntervalBlocks, "block_counter", time.Second, a.RT) |
|||
time.Sleep(updateIntervalBlocks * time.Second) |
|||
} |
|||
} |
|||
}() |
|||
} |
|||
|
|||
func (a *WalletApplication) pollTokenBalance() { |
|||
go func() { |
|||
retryCounter := 1 |
|||
for { |
|||
select { |
|||
case <-a.killSignal: |
|||
return |
|||
default: |
|||
time.Sleep(time.Duration(retryCounter) * time.Second) // Incremental backoff
|
|||
for retryCounter <= 20 && a.wallet.Address != "" { |
|||
|
|||
balance, err := a.GetTokenBalance() |
|||
if err != nil { |
|||
retryCounter++ |
|||
break |
|||
} |
|||
a.log.Infoln("Current Balance: ", balance) |
|||
a.wallet.Balance, a.wallet.AvailableBalance, a.wallet.TotalBalance = balance, balance, balance |
|||
a.RT.Events.Emit("token", a.wallet.Balance, a.wallet.AvailableBalance, a.wallet.TotalBalance) |
|||
UpdateCounter(updateIntervalToken, "token_counter", time.Second, a.RT) |
|||
time.Sleep(updateIntervalToken * time.Second) |
|||
} |
|||
} |
|||
} |
|||
}() |
|||
} |
|||
|
|||
// pricePoller polls the min-api.cryptocompare REST API for DAG token value.
|
|||
// Once polled, it'll Emit the token value to Dashboard.vue for full token
|
|||
// balance evaluation against USD.
|
|||
func (a *WalletApplication) pricePoller() { |
|||
|
|||
const ( |
|||
apiKey string = "17b10afdddc411087e2140ec91bd73d88d0c20294541838b192255fc574b1cb7" |
|||
ticker string = "DAG" |
|||
url string = "https://min-api.cryptocompare.com/data/pricemulti?fsyms=" + ticker + "&tsyms=BTC,USD,EUR&api_key=" + apiKey |
|||
) |
|||
|
|||
go func() { |
|||
retryCounter := 1 |
|||
time.Sleep(3 * time.Second) // Give some space to pollTokenBalance
|
|||
|
|||
for { |
|||
select { |
|||
case <-a.killSignal: |
|||
return |
|||
default: |
|||
a.wallet.TokenPrice.DAG.USD = 0 |
|||
a.wallet.TokenPrice.DAG.EUR = 0 |
|||
a.wallet.TokenPrice.DAG.BTC = 0 |
|||
time.Sleep(time.Duration(retryCounter) * time.Second) // Incremental backoff
|
|||
for retryCounter <= 20 && a.wallet.Balance != 0 { |
|||
a.log.Infoln("Contacting token evaluation API on: " + url + ticker) |
|||
|
|||
resp, err := http.Get(url) |
|||
if err != nil { |
|||
a.log.Warnln("Unable to poll token evaluation. Reason: ", err) // Log this
|
|||
retryCounter++ |
|||
break |
|||
} |
|||
|
|||
if resp == nil { |
|||
retryCounter++ |
|||
a.log.Errorln("Killing pollTokenBalance after 10 failed attempts to get balance from mainnet, Reason: ", err) |
|||
a.sendWarning("Unable to showcase token USD evaluation. Please check your internet connectivity and restart the wallet application.") |
|||
break |
|||
} |
|||
|
|||
body, err := ioutil.ReadAll(resp.Body) |
|||
if err != nil { |
|||
retryCounter++ |
|||
a.sendError("Unable to read HTTP resonse from Token API. Reason: ", err) |
|||
a.log.Warnln("Unable to read HTTP resonse from Token API. Reason: ", err) |
|||
break |
|||
} |
|||
err = json.Unmarshal([]byte(body), &a.wallet.TokenPrice) |
|||
if err != nil { |
|||
retryCounter++ |
|||
a.sendError("Unable to display token price. Reason: ", err) |
|||
a.log.Warnln("Unable to display token price. Reason:", err) |
|||
break |
|||
} |
|||
|
|||
if a.wallet.Balance != 0 && a.wallet.TokenPrice.DAG.USD == 0 { |
|||
|
|||
a.log.Infoln("Contacting alternate token evaluation API") |
|||
a.wallet.TokenPrice.DAG.USD, a.wallet.TokenPrice.DAG.BTC, err = getTokenPriceAlternateRoute() |
|||
|
|||
if err != nil { |
|||
a.log.Errorln("Failed to fetch token metrics using alternate endpoint. Reason: ", err) |
|||
if retryCounter == 10 || retryCounter == 15 || retryCounter == 20 { |
|||
warn := fmt.Sprintf("No data recieved from Token Price API. Will try again in %v seconds.", retryCounter) |
|||
a.sendWarning(warn) |
|||
} |
|||
retryCounter++ |
|||
break |
|||
} |
|||
} |
|||
|
|||
a.log.Infof("Collected token price in USD: %v", a.wallet.TokenPrice.DAG.USD) |
|||
a.log.Infof("Collected token price in EUR: %v", a.wallet.TokenPrice.DAG.EUR) |
|||
a.log.Infof("Collected token price in BTC: %v", a.wallet.TokenPrice.DAG.BTC) |
|||
|
|||
totalCurrencyBalance := 0.0 |
|||
if a.wallet.Currency == "USD" { |
|||
totalCurrencyBalance = float64(a.wallet.Balance) * a.wallet.TokenPrice.DAG.USD |
|||
} else if a.wallet.Currency == "EUR" { |
|||
totalCurrencyBalance = float64(a.wallet.Balance) * a.wallet.TokenPrice.DAG.EUR |
|||
} else if a.wallet.Currency == "BTC" { |
|||
totalCurrencyBalance = float64(a.wallet.Balance) * a.wallet.TokenPrice.DAG.BTC |
|||
} |
|||
a.RT.Events.Emit("totalValue", a.wallet.Currency, totalCurrencyBalance) |
|||
|
|||
UpdateCounter(updateIntervalCurrency, "value_counter", time.Second, a.RT) |
|||
time.Sleep(updateIntervalCurrency * time.Second) |
|||
} |
|||
} |
|||
} |
|||
}() |
|||
} |
|||
|
|||
// StoreTermsOfServiceStateDB stores the Terms of Service state in the user DB
|
|||
func (a *WalletApplication) StoreTermsOfServiceStateDB(termsOfService bool) bool { |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Update("TermsOfService", termsOfService).Error; err != nil { |
|||
a.log.Errorln("Unable to store termsOfService state. Reason: ", err) |
|||
a.sendError("Unable to store termsOfService state persistently. Reason: ", err) |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
|
|||
type tokenPriceAlt struct { |
|||
Code string `json:"code"` |
|||
Data struct { |
|||
Sequence string `json:"sequence"` |
|||
BestAsk string `json:"bestAsk"` |
|||
Size string `json:"size"` |
|||
Price string `json:"price"` |
|||
BestBidSize string `json:"bestBidSize"` |
|||
Time int64 `json:"time"` |
|||
BestBid string `json:"bestBid"` |
|||
BestAskSize string `json:"bestAskSize"` |
|||
} `json:"data"` |
|||
} |
|||
|
|||
// getTokenPriceAlternateRoute will kick in in case the main token poller API will return
|
|||
// broken payload.
|
|||
func getTokenPriceAlternateRoute() (float64, float64, error) { |
|||
|
|||
var tokenpriceUSD, tokenpriceBTC float64 |
|||
var err error |
|||
|
|||
tpa := new(tokenPriceAlt) |
|||
|
|||
const ( |
|||
url = "https://api.kucoin.com/api/v1/market/orderbook/level1?symbol=" |
|||
usdTicker = "DAG-USDT" |
|||
btcTicker = "DAG-BTC" |
|||
) |
|||
|
|||
tickers := []string{usdTicker, btcTicker} |
|||
|
|||
for _, tick := range tickers { |
|||
resp, err := http.Get(url + tick) |
|||
if err != nil { |
|||
return 0, 0, err |
|||
} |
|||
|
|||
// Example resp: {"code":"200000","data":
|
|||
// {"sequence":"1583079038860","bestAsk":"0.01058",
|
|||
// "size":"73908.9903","price":"0.01058",
|
|||
// "bestBidSize":"403.157","time":1589605888009,
|
|||
// "bestBid":"0.010539","bestAskSize":"79091.0097"}}
|
|||
|
|||
if resp == nil { |
|||
return 0, 0, err |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
bodyBytes, err := ioutil.ReadAll(resp.Body) |
|||
if err != nil { |
|||
return 0, 0, err |
|||
} |
|||
|
|||
err = json.Unmarshal(bodyBytes, &tpa) |
|||
if err != nil { |
|||
return 0, 0, err |
|||
} |
|||
|
|||
s := tpa.Data.Price |
|||
balance, err := strconv.ParseFloat(s, 64) |
|||
if err != nil { |
|||
return 0, 0, err |
|||
} |
|||
switch { |
|||
case tick == usdTicker: |
|||
tokenpriceUSD = balance |
|||
case tick == btcTicker: |
|||
tokenpriceBTC = balance |
|||
} |
|||
} |
|||
|
|||
return tokenpriceUSD, tokenpriceBTC, err |
|||
} |
|||
|
|||
// UpdateCounter will count up from the last time a card was updated.
|
|||
func UpdateCounter(countFrom int, counter string, unit time.Duration, runtime *wails.Runtime) { |
|||
go func() { |
|||
for i := countFrom; i > 0; i-- { |
|||
runtime.Events.Emit(counter, i) |
|||
time.Sleep(unit) |
|||
continue |
|||
} |
|||
}() |
|||
} |
@ -1,123 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/json" |
|||
"errors" |
|||
"fmt" |
|||
"io/ioutil" |
|||
"net/http" |
|||
"os" |
|||
"os/exec" |
|||
"runtime" |
|||
) |
|||
|
|||
type UpdateWallet struct { |
|||
currentVersion string |
|||
newVersion string |
|||
mollyBinaryPath string |
|||
dagFolderPath string |
|||
app *WalletApplication |
|||
} |
|||
|
|||
func (u *UpdateWallet) Run() { |
|||
var err error |
|||
|
|||
u.currentVersion = u.app.Version |
|||
u.newVersion = u.GetLatestRelease() |
|||
u.mollyBinaryPath, err = os.Executable() |
|||
if err != nil { |
|||
u.app.log.Errorln("Unable to collect the path of the molly wallet binary. Reason: ", err) |
|||
} |
|||
u.dagFolderPath = u.app.paths.DAGDir |
|||
|
|||
err = u.TriggerUpdate() |
|||
if err != nil { |
|||
u.app.log.Errorln("Unable to Update Molly Wallet. Reason: ", err) |
|||
u.app.sendError("Unable to Update Molly Wallet. Reason: ", err) |
|||
} |
|||
|
|||
} |
|||
|
|||
func (u *UpdateWallet) TriggerUpdate() error { |
|||
|
|||
_, fileExt := getUserOS() |
|||
|
|||
main := u.dagFolderPath + "/update" + fileExt |
|||
args := []string{"-init_dag_path=" + u.dagFolderPath, "-init_molly_path=" + u.mollyBinaryPath, "-new_version=" + u.newVersion, "-upgrade=" + "true"} |
|||
|
|||
cmd := exec.Command(main, args...) |
|||
u.app.log.Infoln("Running command: ", cmd) |
|||
|
|||
var stderr bytes.Buffer |
|||
cmd.Stderr = &stderr // Captures STDERR
|
|||
|
|||
err := cmd.Run() |
|||
if err != nil { |
|||
errFormatted := fmt.Sprint(err) + ": " + stderr.String() |
|||
return errors.New(errFormatted) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func (u *UpdateWallet) GetLatestRelease() string { |
|||
|
|||
const ( |
|||
url = "https://api.github.com/repos/grvlle/constellation_wallet/releases/latest" |
|||
) |
|||
|
|||
resp, err := http.Get(url) |
|||
if err != nil { |
|||
u.app.log.Errorln("Failed to send HTTP request. Reason: ", err) |
|||
return "" |
|||
} |
|||
if resp == nil { |
|||
u.app.log.Errorln("Killing pollTokenBalance after 10 failed attempts to get balance from mainnet, Reason: ", err) |
|||
u.app.sendWarning("Unable to showcase current balance. Please check your internet connectivity and restart the wallet application.") |
|||
return "" |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
bodyBytes, err := ioutil.ReadAll(resp.Body) |
|||
if err != nil { |
|||
u.app.log.Warn("Unable to update token balance. Reason: ", err) |
|||
return "" |
|||
} |
|||
|
|||
var result map[string]interface{} |
|||
|
|||
// Unmarshal or Decode the JSON to the interface.
|
|||
err = json.Unmarshal(bodyBytes, &result) |
|||
if err != nil { |
|||
return "" |
|||
} |
|||
|
|||
release := result["tag_name"] |
|||
bytes := []byte(release.(string)) |
|||
version := string(bytes[1:6]) |
|||
return version |
|||
|
|||
} |
|||
|
|||
// getUserOS returns the users OS as well as the file extension of executables for said OS
|
|||
func getUserOS() (string, string) { |
|||
var osBuild string |
|||
var fileExt string |
|||
|
|||
switch os := runtime.GOOS; os { |
|||
case "darwin": |
|||
osBuild = "darwin" |
|||
fileExt = "" |
|||
case "linux": |
|||
osBuild = "linux" |
|||
fileExt = "" |
|||
case "windows": |
|||
osBuild = "windows" |
|||
fileExt = ".exe" |
|||
default: |
|||
osBuild = "unsupported" |
|||
fileExt = "" |
|||
} |
|||
|
|||
return osBuild, fileExt |
|||
} |
@ -1,167 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"os" |
|||
"runtime" |
|||
"strings" |
|||
|
|||
"github.com/grvlle/constellation_wallet/backend/models" |
|||
"golang.org/x/crypto/bcrypt" |
|||
) |
|||
|
|||
// LoginError takes a string and pushes it to the login screen as an errror
|
|||
func (a *WalletApplication) LoginError(errMsg string) { |
|||
if errMsg != "" { |
|||
a.RT.Events.Emit("login_error", errMsg, true) |
|||
} |
|||
} |
|||
|
|||
// Login is called from the FE when a user logs in with a wallet object
|
|||
// already in the DB
|
|||
func (a *WalletApplication) Login(keystorePath, keystorePassword, keyPassword, alias string) bool { |
|||
|
|||
alias = strings.ToLower(alias) |
|||
a.wallet = models.Wallet{ |
|||
KeyStorePath: keystorePath, |
|||
WalletAlias: alias} |
|||
|
|||
if runtime.GOOS == "windows" && !a.javaInstalled() { |
|||
a.LoginError("Unable to detect your Java path. Please make sure that Java has been installed.") |
|||
return false |
|||
} |
|||
|
|||
if !a.TransactionFinished { |
|||
a.log.Warn("Cannot login to another wallet during a pending transaction.") |
|||
a.LoginError("Cannot login to another wallet during a pending transaction.") |
|||
return false |
|||
} |
|||
|
|||
if keystorePath == "" { |
|||
a.LoginError("Please provide a path to the KeyStore file.") |
|||
return false |
|||
} |
|||
|
|||
if !a.passwordsProvided(keystorePassword, keyPassword, alias) { |
|||
a.log.Warnln("One or more passwords were not provided.") |
|||
return false |
|||
} |
|||
|
|||
os.Setenv("CL_STOREPASS", keystorePassword) |
|||
os.Setenv("CL_KEYPASS", keyPassword) |
|||
|
|||
if err := a.DB.First(&a.wallet, "wallet_alias = ?", alias).Error; err != nil { |
|||
a.log.Errorln("Unable to query database object for existing wallet. Reason: ", err) |
|||
return a.ImportWallet(keystorePath, keystorePassword, keyPassword, alias) |
|||
} |
|||
|
|||
if !a.WalletKeystoreAccess() { |
|||
a.LoginError("Access Denied. Please make sure that you have typed in the correct credentials.") |
|||
return false |
|||
} |
|||
|
|||
if !a.NewUser { |
|||
a.DB.Model(&a.wallet).Update("KeystorePath", keystorePath) |
|||
a.log.Infoln("PrivateKey path: ", keystorePath) |
|||
} |
|||
|
|||
// Check password strings against salted hashes stored in DB. Also make sure KeyStore has been accessed.
|
|||
if a.CheckAccess(keystorePassword, a.wallet.KeystorePasswordHash) && a.CheckAccess(keyPassword, a.wallet.KeyPasswordHash) && a.KeyStoreAccess { |
|||
a.UserLoggedIn = true |
|||
|
|||
// os.Setenv("CL_STOREPASS", keystorePassword)
|
|||
// os.Setenv("CL_KEYPASS", keyPassword)
|
|||
|
|||
} else { |
|||
a.UserLoggedIn = false |
|||
a.LoginError("Access Denied. Please make sure that you have typed in the correct credentials.") |
|||
} |
|||
|
|||
if a.UserLoggedIn && a.KeyStoreAccess && !a.NewUser { |
|||
|
|||
err := a.initWallet(keystorePath) |
|||
if err != nil { |
|||
a.UserLoggedIn = false |
|||
} |
|||
} |
|||
|
|||
a.NewUser = false |
|||
|
|||
return a.UserLoggedIn |
|||
} |
|||
|
|||
// CheckTermsOfService is called from the FE to check the termsOfService has been accepted
|
|||
func (a *WalletApplication) CheckTermsOfService() bool { |
|||
return a.wallet.TermsOfService |
|||
} |
|||
|
|||
// LogOut will reset the wallet UI and clear the wallet objects
|
|||
func (a *WalletApplication) LogOut() bool { |
|||
if a.TransactionFinished { |
|||
a.UserLoggedIn = false |
|||
a.wallet = models.Wallet{} |
|||
return true |
|||
} |
|||
a.sendWarning("Cannot log out while transaction is processing. Please try again.") |
|||
return false |
|||
} |
|||
|
|||
// ImportKey is called from the frontend when browsing the fs for a keyfile
|
|||
func (a *WalletApplication) ImportKey() string { |
|||
var keyfile = a.RT.Dialog.SelectFile() |
|||
if keyfile == "" { |
|||
a.LoginError("Access Denied. No key path detected.") |
|||
return "" |
|||
} |
|||
|
|||
if keyfile[len(keyfile)-4:] != ".p12" { |
|||
a.LoginError("Access Denied. Not a key file.") |
|||
return "" |
|||
} |
|||
a.log.Info("Path to imported key: " + keyfile) |
|||
return keyfile |
|||
} |
|||
|
|||
// SelectDirToStoreKey is called from the FE when creating a new keyfile
|
|||
func (a *WalletApplication) SelectDirToStoreKey() string { |
|||
|
|||
var keyfile = a.RT.Dialog.SelectSaveFile() |
|||
|
|||
if len(keyfile) <= 0 { |
|||
a.LoginError("No valid path were provided. Please try again.") |
|||
return "" |
|||
} |
|||
if keyfile[len(keyfile)-4:] != ".p12" { |
|||
keyfile = keyfile + ".p12" |
|||
return keyfile |
|||
} |
|||
return keyfile |
|||
} |
|||
|
|||
// GenerateSaltedHash converts plain text to a salted hash
|
|||
func (a *WalletApplication) GenerateSaltedHash(s string) (string, error) { |
|||
saltedBytes := []byte(s) |
|||
hashedBytes, err := bcrypt.GenerateFromPassword(saltedBytes, bcrypt.DefaultCost) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
hash := string(hashedBytes[:]) |
|||
return hash, nil |
|||
} |
|||
|
|||
// CheckAccess verifies that the user has entered the correct password
|
|||
func (a *WalletApplication) CheckAccess(password, passwordHash string) bool { |
|||
err := a.Compare(password, passwordHash) |
|||
if err != nil { |
|||
a.log.Warnln("User tried to login with the wrong credentials!") |
|||
return false |
|||
} |
|||
a.log.Infoln("Password check OK") |
|||
return true |
|||
} |
|||
|
|||
// Compare compares a string with a salted hash
|
|||
func (a *WalletApplication) Compare(s, hash string) error { |
|||
incoming := []byte(s) |
|||
existing := []byte(hash) |
|||
return bcrypt.CompareHashAndPassword(existing, incoming) |
|||
} |
@ -1,4 +0,0 @@ |
|||
package models |
|||
|
|||
// Address holds the DAG address
|
|||
type Address string |
@ -1,10 +0,0 @@ |
|||
package models |
|||
|
|||
// Path carries the file paths
|
|||
type Path struct { |
|||
ID uint `json:"id"` |
|||
Alias string `json:"alias"` |
|||
LastTXFile string |
|||
PrevTXFile string |
|||
EmptyTXFile string |
|||
} |
@ -1,19 +0,0 @@ |
|||
package models |
|||
|
|||
// TXHistory stores inidividual transactions
|
|||
type TXHistory struct { |
|||
ID uint `json:"id"` |
|||
Alias string `json:"alias"` |
|||
Amount int64 `json:"amount"` |
|||
Sender string `json:"sender"` |
|||
Receiver string `json:"receiver"` |
|||
Fee int64 `json:"fee"` |
|||
Hash string `json:"hash"` |
|||
LastTransactionRef struct { |
|||
Hash string `json:"prevHash"` |
|||
Ordinal int `json:"ordinal"` |
|||
} `json:"lastTransactionRef"` |
|||
TS string `json:"date"` |
|||
Status string `json:"status"` |
|||
Failed bool |
|||
} |
@ -1,41 +0,0 @@ |
|||
package models |
|||
|
|||
import ( |
|||
"time" |
|||
|
|||
_ "github.com/jinzhu/gorm/dialects/sqlite" |
|||
) |
|||
|
|||
// Wallet holds all wallet information.
|
|||
type Wallet struct { |
|||
ID uint `gorm:"AUTO_INCREMENT"` |
|||
CreatedAt time.Time |
|||
UpdatedAt time.Time |
|||
DeletedAt *time.Time |
|||
Path Path `gorm:"foreignkey:Alias"` |
|||
KeystorePasswordHash string |
|||
KeyPasswordHash string |
|||
KeyStorePath string |
|||
WalletAlias string `gorm:"primary_key;unique"` |
|||
Addresses []Address `sql:"-"` |
|||
TXHistory []TXHistory `gorm:"foreignkey:Alias"` |
|||
ProfilePicture string |
|||
WalletTag string |
|||
DarkMode bool |
|||
Currency string |
|||
TermsOfService bool |
|||
Balance float64 `json:"balance"` |
|||
AvailableBalance float64 `json:"available_balance"` |
|||
Nonce float64 `json:"nonce"` |
|||
TotalBalance float64 `json:"total_balance"` |
|||
Delegated float64 `json:"delegated"` |
|||
Deposit float64 `json:"deposit"` |
|||
Address string `json:"address"` |
|||
TokenPrice struct { |
|||
DAG struct { |
|||
BTC float64 `json:"BTC,omitempty"` |
|||
USD float64 `json:"USD,omitempty"` |
|||
EUR float64 `json:"EUR,omitempty"` |
|||
} `json:"DAG"` |
|||
} `json:"token_price"` |
|||
} |
@ -1,166 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"image" |
|||
"image/jpeg" |
|||
"io" |
|||
"os" |
|||
"strings" |
|||
) |
|||
|
|||
// UploadImage will forward the image path of the selected image.
|
|||
func (a *WalletApplication) UploadImage() string { |
|||
filePath := a.RT.Dialog.SelectFile() |
|||
splitPath := strings.Split(filePath, "/") |
|||
filename := splitPath[len(splitPath)-1] |
|||
|
|||
a.log.Info("Path to user uploaded image: " + filePath) |
|||
err := CopyFile(filePath, a.paths.ImageDir+filename) |
|||
if err != nil && filePath != "" { |
|||
a.log.Errorln("Unable to copy image. ", err) |
|||
a.sendError("Unable to change Image. ", err) |
|||
return "None" |
|||
} |
|||
|
|||
file, err := os.Open(filePath) |
|||
if err != nil && filePath != "" { |
|||
a.log.Errorln("Unable to open image. ", err) |
|||
a.sendError("Unable to find Image on the path provided. ", err) |
|||
return "None" |
|||
} |
|||
defer file.Close() |
|||
|
|||
img, _, err := image.DecodeConfig(file) |
|||
if err != nil { |
|||
a.log.Info("Attempting to decode as JPEG") |
|||
img, err = jpeg.DecodeConfig(file) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to decode image configuration", err) |
|||
a.sendError("Unable to change Image. ", err) |
|||
return "None" |
|||
} |
|||
} |
|||
|
|||
a.log.Info("Uploaded image resolution is set to ", img.Height, "x", img.Width) |
|||
|
|||
if img.Height >= 201 || img.Width >= 201 { |
|||
a.log.Warnf("Image resolution is too big. Cannot be bigger than 200x200 ") |
|||
|
|||
return "None" |
|||
} |
|||
a.StoreImagePathInDB(filename) |
|||
return filename |
|||
} |
|||
|
|||
// GetImagePath is called from the Login.Vue. It'll query the DB for the user's profile picture
|
|||
// and return it to the FE to be displayed.
|
|||
func (a *WalletApplication) GetImagePath() string { |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Error; err != nil { |
|||
a.log.Errorln("Unable to query the DB record for the Image path. Reason: ", err) |
|||
a.sendError("Unable to query the DB record for the Image path. Reason: ", err) |
|||
return "" |
|||
} |
|||
a.log.Infoln("Profile Picture selected: ", a.wallet.ProfilePicture) |
|||
return a.wallet.ProfilePicture |
|||
} |
|||
|
|||
// StoreImagePathInDB stores the path to where the profile picture is located in the database
|
|||
func (a *WalletApplication) StoreImagePathInDB(path string) bool { |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Update("ProfilePicture", path).Error; err != nil { |
|||
a.log.Errorln("Unable to update the DB record with the Image path. Reason: ", err) |
|||
a.sendError("Unable to update the DB record with the Image path. Reason: ", err) |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
|
|||
// GetWalletTag is called from the Login.Vue
|
|||
func (a *WalletApplication) GetWalletTag() string { |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Error; err != nil { |
|||
a.log.Errorln("Unable to query the DB record for the Image path. Reason: ", err) |
|||
a.sendError("Unable to query the DB record for the Image path. Reason: ", err) |
|||
} |
|||
a.log.Infoln("Wallet Tag selected: ", a.wallet.WalletTag) |
|||
return a.wallet.WalletTag |
|||
} |
|||
|
|||
// StoreWalletLabelInDB takes a wallet label string entered by a user and stores it in the database
|
|||
func (a *WalletApplication) StoreWalletLabelInDB(walletTag string) bool { |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Update("WalletTag", walletTag).Error; err != nil { |
|||
a.log.Errorln("Unable to update the DB record with the wallet tag. Reason: ", err) |
|||
a.sendError("Unable to update the DB record with the wallet tag. Reason: ", err) |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
|
|||
// GetUserTheme is called from the Login.Vue
|
|||
func (a *WalletApplication) GetUserTheme() bool { |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Error; err != nil { |
|||
a.log.Errorln("Unable to query the DB record for the Image path. Reason: ", err) |
|||
a.sendError("Unable to query the DB record for the Image path. Reason: ", err) |
|||
} |
|||
if a.wallet.DarkMode { |
|||
a.log.Infoln("Dark mode enabled") |
|||
} |
|||
|
|||
return a.wallet.DarkMode |
|||
} |
|||
|
|||
// StoreDarkModeStateDB stores the darkmode state in the user DB
|
|||
func (a *WalletApplication) StoreDarkModeStateDB(darkMode bool) bool { |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Update("DarkMode", darkMode).Error; err != nil { |
|||
a.log.Errorln("Unable to store darkmode state. Reason: ", err) |
|||
a.sendError("Unable to store darkmode state persistently. Reason: ", err) |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
|
|||
// StoreCurrencyStateDB stores the currency state in the user DB
|
|||
func (a *WalletApplication) StoreCurrencyStateDB(currency string) bool { |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Update("Currency", currency).Error; err != nil { |
|||
a.log.Errorln("Unable to store currency state. Reason: ", err) |
|||
a.sendError("Unable to store currency state persistently. Reason: ", err) |
|||
return false |
|||
} |
|||
totalCurrencyBalance := 0.0 |
|||
if a.wallet.Currency == "USD" { |
|||
totalCurrencyBalance = float64(a.wallet.Balance) * a.wallet.TokenPrice.DAG.USD |
|||
} else if a.wallet.Currency == "EUR" { |
|||
totalCurrencyBalance = float64(a.wallet.Balance) * a.wallet.TokenPrice.DAG.EUR |
|||
} else if a.wallet.Currency == "BTC" { |
|||
totalCurrencyBalance = float64(a.wallet.Balance) * a.wallet.TokenPrice.DAG.BTC |
|||
} |
|||
a.RT.Events.Emit("totalValue", a.wallet.Currency, totalCurrencyBalance) |
|||
return true |
|||
} |
|||
|
|||
// UpdateMolly is called from the frontend and triggers the application update
|
|||
func (a *WalletApplication) UpdateMolly() { |
|||
update := new(UpdateWallet) |
|||
update.app = a |
|||
update.Run() |
|||
} |
|||
|
|||
// CopyFile the src file to dst. Any existing file will be overwritten and will not
|
|||
// copy file attributes.
|
|||
func CopyFile(src, dst string) error { |
|||
in, err := os.Open(src) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
defer in.Close() |
|||
|
|||
out, err := os.Create(dst) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
defer out.Close() |
|||
|
|||
_, err = io.Copy(out, in) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return out.Close() |
|||
} |
@ -1,374 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"bufio" |
|||
"bytes" |
|||
"encoding/json" |
|||
"io/ioutil" |
|||
"net/http" |
|||
"os" |
|||
"time" |
|||
|
|||
"github.com/grvlle/constellation_wallet/backend/models" |
|||
) |
|||
|
|||
// Transaction contains all tx information
|
|||
type Transaction struct { |
|||
Edge struct { |
|||
ObservationEdge struct { |
|||
Parents []struct { |
|||
HashReference string `json:"hashReference"` |
|||
HashType string `json:"hashType"` |
|||
BaseHash string `json:"baseHash"` |
|||
} `json:"parents"` |
|||
Data struct { |
|||
HashReference string `json:"hashReference"` |
|||
HashType string `json:"hashType"` |
|||
BaseHash string `json:"baseHash"` |
|||
} `json:"data"` |
|||
} `json:"observationEdge"` |
|||
SignedObservationEdge struct { |
|||
SignatureBatch struct { |
|||
Hash string `json:"hash"` |
|||
Signatures []struct { |
|||
Signature string `json:"signature"` |
|||
ID struct { |
|||
Hex string `json:"hex"` |
|||
} `json:"id"` |
|||
} `json:"signatures"` |
|||
} `json:"signatureBatch"` |
|||
} `json:"signedObservationEdge"` |
|||
Data struct { |
|||
Amount int64 `json:"amount"` |
|||
LastTxRef struct { |
|||
PrevHash string `json:"prevHash"` |
|||
Ordinal int `json:"ordinal"` |
|||
} `json:"lastTxRef"` |
|||
Fee int64 `json:"fee,omitempty"` |
|||
Salt int64 `json:"salt"` |
|||
} `json:"data"` |
|||
} `json:"edge"` |
|||
LastTxRef struct { |
|||
PrevHash string `json:"prevHash"` |
|||
Ordinal int `json:"ordinal"` |
|||
} `json:"lastTxRef"` |
|||
IsDummy bool `json:"isDummy"` |
|||
IsTest bool `json:"isTest"` |
|||
} |
|||
|
|||
/* Send a transaction */ |
|||
|
|||
// TriggerTXFromFE will initate a new transaction triggered from the frontend.
|
|||
func (a *WalletApplication) TriggerTXFromFE(amount float64, fee float64, address string) bool { |
|||
amountConverted := int64(amount * 1e8) |
|||
feeConverted := int64(fee * 1e8) |
|||
|
|||
a.PrepareTransaction(amountConverted, feeConverted, address) |
|||
for !a.TransactionFinished { |
|||
time.Sleep(1 * time.Second) |
|||
} |
|||
return a.TransactionFailed |
|||
} |
|||
|
|||
// PrepareTransaction is triggered from the frontend (Transaction.vue) and will initialize a new tx.
|
|||
// methods called are defined in buildchain.go
|
|||
func (a *WalletApplication) PrepareTransaction(amount int64, fee int64, address string) { |
|||
|
|||
balance, err := a.GetTokenBalance() |
|||
if err != nil { |
|||
a.log.Errorln("Error when querying wallet balance. Reason: ", err) |
|||
a.sendWarning("Unable to poll balance for wallet. Please try again later.") |
|||
a.TransactionFailed = true |
|||
return |
|||
} |
|||
|
|||
if amount+fee > int64(balance*1e8) { |
|||
a.log.Warnf("Trying to send: %d", amount+fee) |
|||
a.log.Warnf("Insufficient Balance: %d", int64(balance*1e8)) |
|||
a.sendWarning("Insufficent Balance.") |
|||
a.TransactionFailed = true |
|||
return |
|||
} |
|||
|
|||
if a.TransactionFinished { |
|||
a.TransactionFinished = false |
|||
|
|||
// Asynchronously inform FE of TX state in wallet.
|
|||
go func() { |
|||
for !a.TransactionFinished { |
|||
a.RT.Events.Emit("tx_in_transit", a.TransactionFinished) |
|||
time.Sleep(1 * time.Second) |
|||
} |
|||
a.RT.Events.Emit("tx_in_transit", a.TransactionFinished) |
|||
}() |
|||
ptx := a.loadTXFromFile(a.paths.PrevTXFile) |
|||
ltx := a.loadTXFromFile(a.paths.LastTXFile) |
|||
|
|||
ptxObj, ltxObj := a.convertToTXObject(ptx, ltx) |
|||
|
|||
a.formTXChain(amount, fee, address, ptxObj, ltxObj) |
|||
} |
|||
} |
|||
|
|||
func (a *WalletApplication) putTXOnNetwork(tx *Transaction) (bool, string) { |
|||
a.log.Info("Attempting to communicate with mainnet on: " + a.Network.URL + a.Network.Handles.Transaction) |
|||
/* TEMPORARILY COMMENTED OUT */ |
|||
a.log.Warnln("TX Ordinal:", tx.Edge.Data.LastTxRef.Ordinal) |
|||
bytesRepresentation, err := json.Marshal(tx) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to parse JSON data for transaction", err) |
|||
a.sendError("Unable to parse JSON data for transaction", err) |
|||
return false, "" |
|||
} |
|||
resp, err := http.Post(a.Network.URL+a.Network.Handles.Transaction, "application/json", bytes.NewBuffer(bytesRepresentation)) |
|||
if err != nil { |
|||
a.log.Errorln("Failed to send HTTP request. Reason: ", err) |
|||
a.sendError("Unable to send request to mainnet. Please check your internet connection. Reason: ", err) |
|||
return false, "" |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.StatusCode == http.StatusOK { |
|||
bodyBytes, err := ioutil.ReadAll(resp.Body) |
|||
if err != nil { |
|||
a.log.Errorln(string(bodyBytes)) |
|||
a.log.Errorln("Failed to read the response body. Reason: ", err) |
|||
} |
|||
|
|||
bodyString := string(bodyBytes[1:65]) |
|||
a.log.Infoln("The bytesize of the request body: ", len(bodyBytes)) |
|||
if len(bodyBytes) == 66 { |
|||
a.log.Info("Transaction Hash: ", bodyString) |
|||
a.TxPending(bodyString) |
|||
a.log.Infoln("Transaction has been successfully sent to the network.") |
|||
a.sendSuccess("Transaction successfully sent!") |
|||
return true, bodyString |
|||
} |
|||
a.log.Warn(bodyString) |
|||
a.sendWarning("Unable to put transaction on the network. Reason: " + bodyString) |
|||
return false, "" |
|||
} |
|||
|
|||
bodyBytes, err := ioutil.ReadAll(resp.Body) |
|||
if err != nil { |
|||
a.log.Errorln(err) |
|||
} |
|||
bodyString := string(bodyBytes) |
|||
a.sendError("Unable to communicate with mainnet. Reason: "+bodyString, err) |
|||
a.log.Errorln("Unable to put TX on the network. HTTP Code: " + string(resp.StatusCode) + " - " + bodyString) |
|||
|
|||
return false, "" |
|||
} |
|||
|
|||
func (a *WalletApplication) sendTransaction(txFile string) *models.TXHistory { |
|||
|
|||
txObject := a.loadTXFromFile(txFile) |
|||
|
|||
tx := &Transaction{} |
|||
|
|||
bytes := []byte(txObject) |
|||
err := json.Unmarshal(bytes, &tx) |
|||
if err != nil { |
|||
a.sendError("Unable to parse the last transaction. Reason:", err) |
|||
a.log.Errorf("Unable to parse contents of last_tx. Reason: %s", err) |
|||
return nil |
|||
} |
|||
|
|||
// Put TX object on network
|
|||
TXSuccessfullyPutOnNetwork, hash := a.putTXOnNetwork(tx) |
|||
if TXSuccessfullyPutOnNetwork { |
|||
txData := &models.TXHistory{ |
|||
Amount: tx.Edge.Data.Amount, |
|||
Receiver: tx.Edge.ObservationEdge.Parents[1].HashReference, |
|||
Fee: tx.Edge.Data.Fee, |
|||
Hash: hash, |
|||
TS: time.Now().Format("Jan _2 15:04:05"), |
|||
Status: "Pending", |
|||
Failed: false, |
|||
} |
|||
a.storeTX(txData) |
|||
a.RT.Events.Emit("new_transaction", txData) // Pass the tx to the frontend as a new transaction.
|
|||
a.TransactionFinished = true |
|||
a.TransactionFailed = false |
|||
return txData |
|||
} |
|||
txData := &models.TXHistory{ |
|||
Amount: tx.Edge.Data.Amount, |
|||
Receiver: tx.Edge.ObservationEdge.Parents[1].HashReference, |
|||
Fee: tx.Edge.Data.Fee, |
|||
Hash: hash, |
|||
TS: time.Now().Format("Jan _2 15:04:05"), |
|||
Status: "Error", |
|||
Failed: true, |
|||
} |
|||
a.log.Errorln("TX Failed, storing with failed state.") |
|||
a.storeTX(txData) |
|||
a.TransactionFinished = true |
|||
a.TransactionFailed = true |
|||
return txData |
|||
} |
|||
|
|||
func (a *WalletApplication) storeTX(txData *models.TXHistory) { |
|||
|
|||
if txData == nil { |
|||
return |
|||
} |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Association("TXHistory").Append(txData).Error; err != nil { |
|||
a.log.Errorln("Unable to update the DB record with the new TX. Reason: ", err) |
|||
a.sendError("Unable to update the DB record with the new TX. Reason: ", err) |
|||
} |
|||
a.log.Infoln("Successfully stored tx in DB") |
|||
} |
|||
|
|||
// loadTXFromFile takes a file, scans it and returns it in an object
|
|||
func (a *WalletApplication) loadTXFromFile(txFile string) string { |
|||
var txObjects string |
|||
|
|||
fi, err := os.Stat(txFile) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to stat last_tx. Reason: ", err) |
|||
a.sendError("Unable to stat last_tx. Reason: ", err) |
|||
return "" |
|||
} |
|||
// get the size
|
|||
size := fi.Size() |
|||
if size <= 0 { |
|||
a.log.Info("TX file is empty.") |
|||
return "" |
|||
} |
|||
|
|||
file, err := os.Open(txFile) // acct
|
|||
if err != nil { |
|||
a.log.Errorln("Unable to open TX file. Reason: ", err) |
|||
a.sendError("Unable to read last tx. Aborting... Reason: ", err) |
|||
return "" |
|||
} |
|||
|
|||
scanner := bufio.NewScanner(file) |
|||
scanner.Split(bufio.ScanLines) |
|||
|
|||
for scanner.Scan() { |
|||
txObjects = scanner.Text() |
|||
} |
|||
defer file.Close() |
|||
return txObjects |
|||
} |
|||
|
|||
/* Query TX */ |
|||
|
|||
// TxProcessed will query the last transaction. If no answer is returned, it means it's processed and the
|
|||
// method will return true.
|
|||
func (a *WalletApplication) TxProcessed(TXHash string) bool { |
|||
a.log.Info("Communicating with mainnet on: " + a.Network.URL + a.Network.Handles.Transaction + "/" + TXHash) |
|||
|
|||
resp, err := http.Get(a.Network.URL + a.Network.Handles.Transaction + "/" + TXHash) |
|||
if err != nil { |
|||
a.log.Errorln("Failed to send HTTP request. Reason: ", err) |
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Delete(&a.wallet).Error; err != nil { |
|||
a.log.Errorln("Unable to delete wallet upon failed import. Reason: ", err) |
|||
return false |
|||
} |
|||
a.log.Errorln("Unable to verify transaction status. Please check your internet connection.") |
|||
return false |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.Body == nil { |
|||
return false |
|||
} |
|||
|
|||
bodyBytes, err := ioutil.ReadAll(resp.Body) |
|||
if err != nil { |
|||
return false |
|||
} |
|||
|
|||
// Declared an empty interface
|
|||
var result map[string]interface{} |
|||
|
|||
// Unmarshal or Decode the JSON to the interface.
|
|||
err = json.Unmarshal(bodyBytes, &result) |
|||
if err != nil { |
|||
return false |
|||
} |
|||
|
|||
if result["cbBaseHash"] != nil { |
|||
a.log.Infoln("CheckPoint Hash :", result["cbBaseHash"]) |
|||
return true |
|||
} |
|||
|
|||
// null response means it's snapshotted
|
|||
return string(bodyBytes) == "null" |
|||
|
|||
} |
|||
|
|||
type txStatus struct { |
|||
Complete string |
|||
Pending string |
|||
Error string |
|||
} |
|||
|
|||
// TxPending takes a TX Hash and updates the frontend with the current status (Pending/Error/Complete)
|
|||
func (a *WalletApplication) TxPending(TXHash string) { |
|||
|
|||
status := &txStatus{ |
|||
Complete: "Complete", |
|||
Pending: "Pending", |
|||
Error: "Error", |
|||
} |
|||
|
|||
consensus := 0 |
|||
|
|||
select { |
|||
case <-a.killSignal: |
|||
return |
|||
default: |
|||
go func() bool { |
|||
for retryCounter := 0; retryCounter < 30; retryCounter++ { |
|||
processed := a.TxProcessed(TXHash) |
|||
if !processed { |
|||
a.log.Warnf("Transaction %v pending", TXHash) |
|||
a.RT.Events.Emit("tx_pending", status.Pending) |
|||
time.Sleep(time.Duration(retryCounter) * time.Second) // Increase polling interval
|
|||
|
|||
if retryCounter == 29 { |
|||
// Register failed transaction
|
|||
a.sendWarning("Unable to get verification of processed transaction from the network. Please try again later.") |
|||
a.log.Errorf("Unable to get status from the network on transaction: %s", TXHash) |
|||
a.RT.Events.Emit("tx_pending", status.Error) |
|||
if err := a.DB.Table("tx_histories").Where("hash = ?", TXHash).Updates(map[string]interface{}{"status": status.Error, "failed": true}).Error; err != nil { |
|||
a.log.Errorln("Unable to query database object for the imported wallet. Reason: ", err) |
|||
a.LoginError("Unable to query database object for the imported wallet.") |
|||
return false |
|||
} |
|||
a.RT.Events.Emit("update_tx_history", []models.TXHistory{}) // Clear TX history
|
|||
a.initTXFromDB() |
|||
return false |
|||
} |
|||
|
|||
consensus = 0 // Reset consensus
|
|||
} |
|||
if processed && consensus != 3 { |
|||
consensus++ |
|||
a.log.Infof("TX status check has reached consensus %v/3", consensus) |
|||
time.Sleep(1 * time.Second) |
|||
} |
|||
if processed && consensus == 3 { // Need five consecetive confirmations that TX has been processed.
|
|||
break |
|||
} |
|||
|
|||
} |
|||
a.log.Infof("Transaction %v has been successfully processed", TXHash) |
|||
a.sendSuccess("Transaction " + TXHash[:30] + "... has been successfully processed") |
|||
if err := a.DB.Table("tx_histories").Where("hash = ?", TXHash).UpdateColumn("status", status.Complete).Error; err != nil { |
|||
a.log.Errorln("Unable to query database object for the imported wallet. Reason: ", err) |
|||
a.LoginError("Unable to query database object for the imported wallet.") |
|||
return false |
|||
} |
|||
a.RT.Events.Emit("tx_pending", status.Complete) |
|||
a.RT.Events.Emit("update_tx_history", []models.TXHistory{}) // Clear TX history
|
|||
a.initTXFromDB() |
|||
return true |
|||
|
|||
}() |
|||
} |
|||
} |
@ -1,188 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"bytes" |
|||
"encoding/hex" |
|||
"fmt" |
|||
"io" |
|||
"io/ioutil" |
|||
"math/rand" |
|||
"net/http" |
|||
"os" |
|||
"os/exec" |
|||
"path/filepath" |
|||
"runtime" |
|||
"strings" |
|||
"time" |
|||
|
|||
"github.com/dustin/go-humanize" |
|||
) |
|||
|
|||
// newReleaseAvailable generates a notification to FE everytime a new release on
|
|||
// GitHub doesn't match a.Version.
|
|||
func (a *WalletApplication) newReleaseAvailable() { |
|||
update := new(UpdateWallet) |
|||
update.app = a |
|||
currentRelease := a.Version |
|||
|
|||
a.log.Infoln("Checking for new releases...") |
|||
|
|||
go func() { |
|||
for i := 200; i > 0; i-- { |
|||
newRelease := update.GetLatestRelease() |
|||
if currentRelease != newRelease { |
|||
a.log.Infoln("There's a newer release available") |
|||
a.RT.Events.Emit("new_release", newRelease) |
|||
} |
|||
time.Sleep(time.Duration(i) * time.Second) |
|||
} |
|||
}() |
|||
|
|||
} |
|||
|
|||
func (a *WalletApplication) javaInstalled() bool { |
|||
var javaInstalled bool |
|||
if a.paths.Java[len(a.paths.Java)-9:] != "javaw.exe" { |
|||
javaInstalled = false |
|||
} else { |
|||
javaInstalled = true |
|||
} |
|||
return javaInstalled |
|||
} |
|||
|
|||
func (a *WalletApplication) detectJavaPath() { |
|||
|
|||
if runtime.GOOS == "windows" { |
|||
var jwPath string |
|||
|
|||
cmd := exec.Command("cmd", "/c", "where", "java") |
|||
a.log.Infoln("Running command: ", cmd) |
|||
|
|||
var out bytes.Buffer |
|||
var stderr bytes.Buffer |
|||
cmd.Stdout = &out // Captures STDOUT
|
|||
cmd.Stderr = &stderr // Captures STDERR
|
|||
|
|||
err := cmd.Run() |
|||
if err != nil { |
|||
errFormatted := fmt.Sprint(err) + ": " + stderr.String() |
|||
a.log.Errorf(errFormatted) |
|||
a.LoginError("Unable to find Java Installation") |
|||
a.paths.Java = "No valid path detected" |
|||
return |
|||
} |
|||
jPath := out.String() // May contain multiple
|
|||
if jPath == "" { |
|||
a.LoginError("Unable to find Java Installation") |
|||
a.paths.Java = "No valid path detected" |
|||
return |
|||
} |
|||
s := strings.Split(strings.Replace(jPath, "\r\n", "\n", -1), "\n") |
|||
jwPath = string(s[0][:len(s[0])-4]) + "w.exe" // Shifting to javaw.exe
|
|||
if s[1] != "" { |
|||
jwPath = string(s[1][:len(s[1])-4]) + "w.exe" // Shifting to javaw.exe
|
|||
a.log.Info("Detected a secondary java path. Using that over the first one.") |
|||
} |
|||
a.log.Infoln("Java path selected: " + jwPath) |
|||
a.log.Debugln(cmd) |
|||
a.paths.Java = jwPath |
|||
} |
|||
} |
|||
|
|||
//normalizeAmounts takes amount/fee in int64 and normalizes it. Example: passing 821500000000 will return 8215
|
|||
func normalizeAmounts(i int64) (string, error) { |
|||
f := fmt.Sprintf("%.8f", float64(i)/1e8) |
|||
return f, nil |
|||
} |
|||
|
|||
// TempFileName creates temporary file names for the transaction files
|
|||
func (a *WalletApplication) TempFileName(prefix string) string { |
|||
randBytes := make([]byte, 16) |
|||
rand.Read(randBytes) |
|||
return filepath.Join(a.paths.TMPDir, prefix+hex.EncodeToString(randBytes)) |
|||
} |
|||
|
|||
// WriteCounter stores dl state of the cl binaries
|
|||
type WriteCounter struct { |
|||
Total uint64 |
|||
LastEmit uint64 |
|||
Filename string |
|||
a *WalletApplication |
|||
} |
|||
|
|||
// Write emits the download progress of the CL binaries to the frontend
|
|||
func (wc *WriteCounter) Write(p []byte) (int, error) { |
|||
n := len(p) |
|||
wc.Total += uint64(n) |
|||
|
|||
if (wc.Total - wc.LastEmit) > uint64(800) { |
|||
wc.a.RT.Events.Emit("downloading", wc.Filename, humanize.Bytes(wc.Total)) |
|||
wc.LastEmit = wc.Total |
|||
} |
|||
|
|||
return n, nil |
|||
} |
|||
|
|||
func (a *WalletApplication) fetchWalletJar(filename string, filepath string) error { |
|||
url := a.WalletCLI.URL + "/v" + a.WalletCLI.Version + "/" + filename |
|||
a.log.Info(url) |
|||
|
|||
out, err := os.Create(filepath + ".tmp") |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
resp, err := http.Get(url) |
|||
if err != nil { |
|||
out.Close() |
|||
return err |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
counter := &WriteCounter{} |
|||
counter.a = a |
|||
counter.Filename = filename |
|||
counter.LastEmit = uint64(0) |
|||
|
|||
if _, err = io.Copy(out, io.TeeReader(resp.Body, counter)); err != nil { |
|||
out.Close() |
|||
return err |
|||
} |
|||
|
|||
out.Close() |
|||
|
|||
if err = os.Rename(filepath+".tmp", filepath); err != nil { |
|||
return err |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (a *WalletApplication) directoryCreator(directories ...string) error { |
|||
for _, d := range directories { |
|||
err := os.MkdirAll(d, os.ModePerm) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
func (a *WalletApplication) fileExists(path string) bool { |
|||
info, err := os.Stat(path) |
|||
if os.IsNotExist(err) { |
|||
return false |
|||
} |
|||
return !info.IsDir() |
|||
} |
|||
|
|||
// WriteToFile will print any string of text to a file safely by
|
|||
// checking for errors and syncing at the end.
|
|||
func WriteToFile(filename string, data []byte) error { |
|||
|
|||
err := ioutil.WriteFile(filename, data, 0666) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
return nil |
|||
} |
@ -1,542 +0,0 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"encoding/json" |
|||
"errors" |
|||
"fmt" |
|||
"io/ioutil" |
|||
"net/http" |
|||
"os" |
|||
"runtime" |
|||
"strconv" |
|||
"strings" |
|||
"time" |
|||
|
|||
"github.com/grvlle/constellation_wallet/backend/models" |
|||
) |
|||
|
|||
// ImportWallet is triggered when a user logs into a new Molly wallet for the first time
|
|||
func (a *WalletApplication) ImportWallet(keystorePath, keystorePassword, keyPassword, alias string) bool { |
|||
|
|||
alias = strings.ToLower(alias) |
|||
a.wallet = models.Wallet{ |
|||
KeyStorePath: keystorePath, |
|||
WalletAlias: alias, |
|||
Currency: "USD"} |
|||
|
|||
if runtime.GOOS == "windows" && !a.javaInstalled() { |
|||
a.LoginError("Unable to detect your Java path. Please make sure that Java has been installed.") |
|||
return false |
|||
} |
|||
|
|||
if !a.TransactionFinished { |
|||
a.log.Warn("Cannot Import wallet in a pending transaction.") |
|||
a.LoginError("Cannot import a new wallet while there's a pending transaction.") |
|||
return false |
|||
} |
|||
|
|||
if keystorePath == "" { |
|||
a.LoginError("Please provide a path to the KeyStore file.") |
|||
return false |
|||
} |
|||
|
|||
if !a.passwordsProvided(keystorePassword, keyPassword, alias) { |
|||
a.log.Warnln("One or more passwords were not provided.") |
|||
return false |
|||
} |
|||
|
|||
os.Setenv("CL_STOREPASS", keystorePassword) |
|||
os.Setenv("CL_KEYPASS", keyPassword) |
|||
|
|||
a.wallet.Address = a.GenerateDAGAddress() |
|||
a.KeyStoreAccess = a.WalletKeystoreAccess() |
|||
|
|||
if a.KeyStoreAccess { |
|||
if !a.DB.NewRecord(&a.wallet) { |
|||
keystorePasswordHashed, err := a.GenerateSaltedHash(keystorePassword) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to generate password hash. Reason: ", err) |
|||
a.LoginError("Unable to generate password hash.") |
|||
return false |
|||
} |
|||
|
|||
keyPasswordHashed, err := a.GenerateSaltedHash(keyPassword) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to generate password hash. Reason: ", err) |
|||
a.LoginError("Unable to generate password hash.") |
|||
return false |
|||
} |
|||
|
|||
if err := a.DB.Create(&a.wallet).Error; err != nil { |
|||
a.log.Errorln("Unable to create database object for the imported wallet. Reason: ", err) |
|||
a.LoginError("Unable to create database object for the imported wallet. Maybe it has already been imported? Try to login.") |
|||
return false |
|||
} |
|||
|
|||
a.paths.LastTXFile = a.TempFileName("tx-") |
|||
a.paths.PrevTXFile = a.TempFileName("tx-") |
|||
a.paths.EmptyTXFile = a.TempFileName("tx-") |
|||
|
|||
err = a.createTXFiles() |
|||
if err != nil { |
|||
a.log.Fatalln("Unable to create TX files. Check fs permissions. Reason: ", err) |
|||
a.sendError("Unable to create TX files. Check fs permissions. Reason: ", err) |
|||
} |
|||
|
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Update("Path", models.Path{LastTXFile: a.paths.LastTXFile, PrevTXFile: a.paths.PrevTXFile, EmptyTXFile: a.paths.EmptyTXFile}).Error; err != nil { |
|||
a.log.Errorln("Unable to update the DB record with the tmp tx-paths. Reason: ", err) |
|||
a.sendError("Unable to update the DB record with the tmp tx-paths. Reason: ", err) |
|||
} |
|||
|
|||
if err := a.DB.Where("wallet_alias = ?", a.wallet.WalletAlias).First(&a.wallet).Updates(&models.Wallet{KeyStorePath: keystorePath, KeystorePasswordHash: keystorePasswordHashed, KeyPasswordHash: keyPasswordHashed}).Error; err != nil { |
|||
a.log.Errorln("Unable to query database object for the imported wallet. Reason: ", err) |
|||
a.LoginError("Unable to query database object for the imported wallet.") |
|||
return false |
|||
} |
|||
|
|||
a.UserLoggedIn = false |
|||
a.NewUser = true |
|||
a.WalletImported = true |
|||
err = a.initWallet(keystorePath) |
|||
if err != nil { |
|||
a.log.Errorln("Failed to initialize wallet. Reason: ", err) |
|||
// If unable to import previous transactions, remove wallet from DB and logout.
|
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Delete(&a.wallet).Error; err != nil { |
|||
a.log.Errorln("Unable to delete wallet upon failed import. Reason: ", err) |
|||
return false |
|||
} |
|||
return false |
|||
} |
|||
|
|||
return true |
|||
|
|||
} else if a.DB.NewRecord(&a.wallet) { // There may already be an existing DB record in some circumstances.
|
|||
a.DB.First(&a.wallet) |
|||
|
|||
a.UserLoggedIn = false |
|||
a.NewUser = false |
|||
a.WalletImported = true |
|||
err := a.initWallet(a.wallet.KeyStorePath) |
|||
if err != nil { |
|||
a.log.Errorln("Faled to initialize wallet. Reason: ", err) |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
// CreateWallet is called when creating a new wallet in frontend component Login.vue
|
|||
func (a *WalletApplication) CreateWallet(keystorePath, keystorePassword, keyPassword, alias, label string) bool { |
|||
|
|||
alias = strings.ToLower(alias) |
|||
|
|||
if runtime.GOOS == "windows" && !a.javaInstalled() { |
|||
a.LoginError("Unable to detect your Java path. Please make sure that Java has been installed.") |
|||
return false |
|||
} |
|||
|
|||
if !a.TransactionFinished { |
|||
a.log.Warn("Cannot Create wallet in a pending transaction.") |
|||
a.LoginError("Cannot create a new wallet while there's a pending transaction.") |
|||
return false |
|||
} |
|||
|
|||
if keystorePath == "" { |
|||
a.LoginError("Please provide a path to store the KeyStore file.") |
|||
return false |
|||
} |
|||
|
|||
if !a.passwordsProvided(keystorePassword, keyPassword, alias) { |
|||
a.log.Warnln("One or more passwords were not provided.") |
|||
return false |
|||
} |
|||
|
|||
if alias == "" { |
|||
alias = a.wallet.WalletAlias |
|||
} |
|||
|
|||
os.Setenv("CL_STOREPASS", keystorePassword) |
|||
os.Setenv("CL_KEYPASS", keyPassword) |
|||
|
|||
keystorePasswordHashed, err := a.GenerateSaltedHash(keystorePassword) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to generate password hash. Reason: ", err) |
|||
a.sendError("Unable to generate password hash. Reason: ", err) |
|||
return false |
|||
} |
|||
|
|||
keyPasswordHashed, err := a.GenerateSaltedHash(keyPassword) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to generate password hash. Reason: ", err) |
|||
a.sendError("Unable to generate password hash. Reason: ", err) |
|||
return false |
|||
} |
|||
|
|||
a.wallet = models.Wallet{ |
|||
KeyStorePath: keystorePath, |
|||
KeystorePasswordHash: keystorePasswordHashed, |
|||
KeyPasswordHash: keyPasswordHashed, |
|||
WalletAlias: alias, |
|||
WalletTag: label} |
|||
|
|||
if !a.DB.NewRecord(&a.wallet) { |
|||
if err := a.DB.Create(&a.wallet).Error; err != nil { |
|||
a.log.Errorln("Unable to create database object for new wallet. Reason: ", err) |
|||
a.LoginError("Unable to create new wallet. Alias already exists.") |
|||
return false |
|||
} |
|||
|
|||
if err := a.DB.Where("wallet_alias = ?", alias).First(&a.wallet).Updates(&models.Wallet{KeyStorePath: keystorePath, KeystorePasswordHash: keystorePasswordHashed, KeyPasswordHash: keyPasswordHashed}).Error; err != nil { |
|||
a.log.Errorln("Unable to query database object for new wallet after wallet creation. Reason: ", err) |
|||
a.sendError("Unable to query database object for new wallet after wallet creation. Reason: ", err) |
|||
return false |
|||
} |
|||
|
|||
err = a.CreateEncryptedKeyStore() |
|||
if err != nil { |
|||
return false |
|||
} |
|||
|
|||
a.wallet.Address = a.GenerateDAGAddress() |
|||
|
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Update("Address", a.wallet.Address).Error; err != nil { |
|||
a.log.Errorln("Unable to update db object new wallet, with the DAG address. Reason: ", err) |
|||
a.sendError("Unable to update db object new wallet, with the DAG address. Reason. Reason: ", err) |
|||
} |
|||
a.KeyStoreAccess = a.WalletKeystoreAccess() |
|||
|
|||
if a.KeyStoreAccess { |
|||
a.paths.LastTXFile = a.TempFileName("tx-") |
|||
a.paths.PrevTXFile = a.TempFileName("tx-") |
|||
a.paths.EmptyTXFile = a.TempFileName("tx-") |
|||
|
|||
err := a.createTXFiles() |
|||
if err != nil { |
|||
a.log.Fatalln("Unable to create TX files. Check fs permissions. Reason: ", err) |
|||
a.sendError("Unable to create TX files. Check fs permissions. Reason: ", err) |
|||
} |
|||
|
|||
if err := a.DB.Where("wallet_alias = ?", a.wallet.WalletAlias).First(&a.wallet).Update("Path", models.Path{LastTXFile: a.paths.LastTXFile, PrevTXFile: a.paths.PrevTXFile, EmptyTXFile: a.paths.EmptyTXFile}).Error; err != nil { |
|||
a.log.Errorln("Unable to update the DB record with the tmp tx-paths. Reason: ", err) |
|||
a.sendError("Unable to update the DB record with the tmp tx-paths. Reason: ", err) |
|||
} |
|||
|
|||
a.UserLoggedIn = false |
|||
a.FirstTX = true |
|||
a.NewUser = true |
|||
|
|||
a.initNewWallet() |
|||
|
|||
return true |
|||
} |
|||
} else { |
|||
a.LoginError("Unable to create new wallet. Alias already exists.") |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
// initWallet initializes a new wallet. This is called from login.vue
|
|||
// only when a new wallet is created.
|
|||
func (a *WalletApplication) initNewWallet() { |
|||
|
|||
a.StoreImagePathInDB("faces/face-0.jpg") |
|||
|
|||
//a.initTransactionHistory()
|
|||
a.passKeysToFrontend() |
|||
|
|||
if !a.WidgetRunning.DashboardWidgets { |
|||
a.initDashboardWidgets() |
|||
} |
|||
a.log.Infoln("A New wallet has been created successfully!") |
|||
} |
|||
|
|||
// initExistingWallet queries the database for the user wallet and pushes
|
|||
// the information to the front end components.
|
|||
func (a *WalletApplication) initWallet(keystorePath string) error { |
|||
|
|||
if a.NewUser { |
|||
err := a.initTXFromBlockExplorer() |
|||
if err != nil { |
|||
return err |
|||
} |
|||
a.StoreImagePathInDB("faces/face-0.jpg") |
|||
} else if !a.NewUser { |
|||
a.initTXFromDB() // Disregard upon import
|
|||
a.initTXFilePath() // Update paths from DB.
|
|||
} |
|||
|
|||
a.RT.Events.Emit("wallet_init", a.wallet.TermsOfService, a.wallet.Currency) |
|||
|
|||
if !a.WidgetRunning.DashboardWidgets { |
|||
a.initDashboardWidgets() |
|||
} |
|||
if !a.WidgetRunning.PassKeysToFrontend { |
|||
a.passKeysToFrontend() |
|||
} |
|||
|
|||
a.log.Infoln("User has logged into the wallet") |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (a *WalletApplication) initDashboardWidgets() { |
|||
// Initializes a struct containing all Chart Data on the dashboard
|
|||
chartData := a.ChartDataInit() |
|||
|
|||
// Below methods are continously updating the client side modules.
|
|||
a.pollTokenBalance() |
|||
a.nodeStats(chartData) |
|||
a.txStats(chartData) |
|||
a.networkStats(chartData) |
|||
a.blockAmount() |
|||
a.pricePoller() |
|||
|
|||
a.WidgetRunning.DashboardWidgets = true |
|||
} |
|||
|
|||
func (a *WalletApplication) createTXFiles() error { |
|||
files := []string{a.paths.LastTXFile, a.paths.PrevTXFile, a.paths.EmptyTXFile} |
|||
|
|||
for _, f := range files { |
|||
file, err := os.Create(f) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
defer file.Close() |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// ImportKeys is called from the frontend to use a file dialog to select key file.
|
|||
func (a *WalletApplication) ImportKeys() error { |
|||
filename := a.RT.Dialog.SelectFile() |
|||
a.log.Info("Path to keys that user wants to import: " + filename) |
|||
return nil |
|||
} |
|||
|
|||
// ExportKeys is called from the frontend to use a file dialog to select directory
|
|||
// where user wants to export the keys to.
|
|||
func (a *WalletApplication) ExportKeys() error { |
|||
filename := a.RT.Dialog.SelectDirectory() |
|||
a.log.Info("File user wants to save to: " + filename) |
|||
return nil |
|||
} |
|||
|
|||
func (a *WalletApplication) initTXFilePath() { |
|||
paths := &a.wallet.Path |
|||
if err := a.DB.Model(&a.wallet).Where("alias = ?", a.wallet.WalletAlias).Association("Path").Find(&paths).Error; err != nil { |
|||
a.log.Fatal("Unable to initialize TX filepaths. Reason: ", err) |
|||
a.sendError("Unable to initialize TX filepaths. Reason: ", err) |
|||
return |
|||
} |
|||
if a.wallet.Path.LastTXFile == "" && a.wallet.Path.PrevTXFile == "" { |
|||
a.log.Fatal("Unable to initialize TX filepaths. Both are empty after DB query.") |
|||
} |
|||
a.paths.LastTXFile = a.wallet.Path.LastTXFile |
|||
a.paths.PrevTXFile = a.wallet.Path.PrevTXFile |
|||
a.paths.EmptyTXFile = a.wallet.Path.EmptyTXFile |
|||
|
|||
} |
|||
|
|||
func (a *WalletApplication) initTXFromDB() { |
|||
if err := a.DB.Model(&a.wallet).Where("alias = ?", a.wallet.WalletAlias).Association("TXHistory").Find(&a.wallet.TXHistory).Error; err != nil { |
|||
a.log.Error("Unable to initialize historic transactions from DB. Reason: ", err) |
|||
a.sendError("Unable to initialize historic transactions from DB. Reason: ", err) |
|||
return |
|||
} |
|||
|
|||
allTX := []models.TXHistory{} |
|||
|
|||
for i, tx := range a.wallet.TXHistory { |
|||
allTX = append([]models.TXHistory{tx}, allTX...) // prepend to reverse list for FE
|
|||
|
|||
if a.wallet.TXHistory[i].Status == "Pending" { |
|||
a.TxPending(a.wallet.TXHistory[i].Hash) |
|||
} |
|||
} |
|||
a.RT.Events.Emit("update_tx_history", allTX) // Pass the tx to the frontend as a new transaction.
|
|||
} |
|||
|
|||
// initTXFromBlockExplorer is called when an existing wallet is imported.
|
|||
func (a *WalletApplication) initTXFromBlockExplorer() error { |
|||
a.log.Info("Sending API call to block explorer on: " + a.Network.BlockExplorer.URL + a.Network.BlockExplorer.Handles.CollectTX + a.wallet.Address) |
|||
|
|||
resp, err := http.Get(a.Network.BlockExplorer.URL + a.Network.BlockExplorer.Handles.CollectTX + a.wallet.Address) |
|||
if err != nil { |
|||
a.log.Errorln("Failed to send HTTP request. Reason: ", err) |
|||
a.LoginError("Unable to collect previous transactions from blockexplorer.") |
|||
return err |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
if resp.Body != nil { |
|||
bodyBytes, err := ioutil.ReadAll(resp.Body) |
|||
if err != nil { |
|||
a.LoginError("Unable to collect previous transactions from blockexplorer. Try again later.") |
|||
a.log.Errorln("Unable to collect previous transactions from blockexplorer. Reason: ", err) |
|||
return err |
|||
} |
|||
|
|||
ok, error := a.verifyAPIResponse(bodyBytes) |
|||
// Blockexplorer returns below string when no previous transactions are found
|
|||
if !ok && error != "Cannot find transactions for sender" { |
|||
a.log.Errorln("API returned the following error", error) |
|||
a.LoginError("The wallet import failed. Please check your internet connection and try again.") |
|||
return errors.New(error) |
|||
} |
|||
|
|||
// If no previous transactions for imported wallet - proceed
|
|||
if !ok && error == "Cannot find transactions for sender" { |
|||
a.log.Info("Unable to detect any previous transactions.") |
|||
return nil |
|||
} |
|||
|
|||
allTX := []models.TXHistory{} |
|||
|
|||
err = json.Unmarshal(bodyBytes, &allTX) |
|||
if err != nil { |
|||
a.log.Errorln("Unable to fetch TX history from block explorer. Reason: ", err) |
|||
a.sendError("Unable to fetch TX history from block explorer. Reason: ", err) |
|||
return err |
|||
} |
|||
|
|||
// Reverse order
|
|||
for i := len(allTX)/2 - 1; i >= 0; i-- { |
|||
opp := len(allTX) - 1 - i |
|||
allTX[i], allTX[opp] = allTX[opp], allTX[i] |
|||
} |
|||
|
|||
a.log.Infof("Successfully collected %d previous transactions. Updating local state...", len(allTX)) |
|||
|
|||
for i, tx := range allTX { |
|||
|
|||
txData := &models.TXHistory{ |
|||
Amount: tx.Amount, |
|||
Receiver: tx.Receiver, |
|||
Fee: tx.Fee, |
|||
Hash: tx.Hash, |
|||
TS: time.Now().Format("Jan _2 15:04:05") + " (imported)", |
|||
Status: "Complete", |
|||
Failed: false, |
|||
} |
|||
a.storeTX(txData) |
|||
a.RT.Events.Emit("new_transaction", txData) |
|||
|
|||
if i+1 == len(allTX) { |
|||
|
|||
err := a.rebuildTxChainState(tx.Hash) |
|||
if err != nil { |
|||
a.log.Errorln(err) |
|||
// If unable to import previous transactions, remove wallet from DB and logout.
|
|||
//TODO: logout
|
|||
if err := a.DB.Model(&a.wallet).Where("wallet_alias = ?", a.wallet.WalletAlias).Delete(&a.wallet).Error; err != nil { |
|||
a.log.Errorln("Unable to delete wallet upon failed import. Reason: ", err) |
|||
return err |
|||
} |
|||
a.log.Panicln("Unable to import previous transactions") |
|||
a.LoginError("Unable to collect previous TX's from blockexplorer. Please try again later.") |
|||
} |
|||
} |
|||
} |
|||
|
|||
} else { |
|||
a.log.Info("Unable to detect any previous transactions.") |
|||
return nil |
|||
} |
|||
return nil |
|||
|
|||
} |
|||
|
|||
// PassKeysToFrontend emits the keys to the settings.Vue component on a
|
|||
// 5 second interval
|
|||
func (a *WalletApplication) passKeysToFrontend() { |
|||
if a.wallet.KeyStorePath != "" && a.wallet.Address != "" { |
|||
go func() { |
|||
for { |
|||
a.RT.Events.Emit("wallet_keys", a.wallet.Address) |
|||
time.Sleep(5 * time.Second) |
|||
} |
|||
}() |
|||
a.WidgetRunning.PassKeysToFrontend = true |
|||
} else { |
|||
a.WidgetRunning.PassKeysToFrontend = false |
|||
} |
|||
} |
|||
|
|||
func (a *WalletApplication) passwordsProvided(keystorePassword, keyPassword, alias string) bool { |
|||
if keystorePassword == "" { |
|||
a.LoginError("Please provide a Key Store password.") |
|||
return false |
|||
} else if keyPassword == "" { |
|||
a.LoginError("Please provide a Key Password.") |
|||
return false |
|||
} else if alias == "" { |
|||
a.LoginError("An Alias has not been provided.") |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
|
|||
// GetTokenBalance polls and parses the token balance of a wallet and returns it as a float64.
|
|||
func (a *WalletApplication) GetTokenBalance() (float64, error) { |
|||
a.log.Debug("Contacting mainnet on: " + a.Network.URL + a.Network.Handles.Balance + " Sending the following payload: " + a.wallet.Address) |
|||
|
|||
resp, err := http.Get(a.Network.URL + a.Network.Handles.Balance + a.wallet.Address) |
|||
if err != nil { |
|||
a.log.Errorln("Failed to send HTTP request. Reason: ", err) |
|||
return 0, err |
|||
} |
|||
if resp == nil { |
|||
a.log.Errorln("Killing pollTokenBalance after 10 failed attempts to get balance from mainnet, Reason: ", err) |
|||
a.sendWarning("Unable to showcase current balance. Please check your internet connectivity and restart the wallet application.") |
|||
return 0, err |
|||
} |
|||
defer resp.Body.Close() |
|||
|
|||
bodyBytes, err := ioutil.ReadAll(resp.Body) |
|||
if err != nil { |
|||
a.log.Warn("Unable to update token balance. Reason: ", err) |
|||
return 0, err |
|||
} |
|||
|
|||
var result map[string]interface{} |
|||
|
|||
// Unmarshal or Decode the JSON to the interface.
|
|||
err = json.Unmarshal(bodyBytes, &result) |
|||
if err != nil { |
|||
return 0, err |
|||
} |
|||
|
|||
s := result["balance"] |
|||
if s == "" { |
|||
s = "0" // Empty means zero
|
|||
} |
|||
|
|||
a.log.Infoln("Parsed the following balance: ", s) |
|||
|
|||
b, ok := s.(float64) |
|||
if !ok { |
|||
if err != nil { |
|||
a.log.Warnln("Unable to parse balance. Reason:", err) |
|||
} |
|||
return 0, err |
|||
} |
|||
|
|||
f := fmt.Sprintf("%.2f", b/1e8) // Reverse normalized float
|
|||
|
|||
a.log.Infoln("Normalized the following balance: ", f) |
|||
|
|||
balance, err := strconv.ParseFloat(f, 64) |
|||
if err != nil { |
|||
a.log.Warnln("Unable to type cast string to float for token balance poller. Check your internet connectivity") |
|||
return 0, err |
|||
} |
|||
|
|||
a.log.Infoln("Returning the following balance: ", balance) |
|||
|
|||
return balance, nil |
|||
} |
@ -1,89 +0,0 @@ |
|||
# Molly Wallet FAQ |
|||
|
|||
This is an attempt to address the most Frequently Asked Questions around the Molly Wallet in hopes of easing the community into leveraging the Desktop Wallet for $DAG to securely store their tokens. |
|||
|
|||
The Molly Wallet is still in the early phases of development with the first stable, mainnet connected build released to the general public mere days ago (at the time of writing this). This means that the way we're distributing the application, and some of the manual steps required will be improved upon with time. |
|||
|
|||
## General Questions |
|||
|
|||
#### Q: Where can I download the Molly Wallet? |
|||
**A:** https://github.com/grvlle/constellation_wallet/releases |
|||
|
|||
#### Q: Why can't I login to my wallet? |
|||
**A:** You can always reset the Molly Wallet, simply by closing the application, removing your `.dag` folder, starting the application and re-importing your key. See the below sections under your OS of choice for how to locate the `.dag` folder. |
|||
|
|||
#### Q: What does the IMPORT feature do on the login screen? |
|||
|
|||
**A:** The Import feature exists to make it possible to access your wallet key from different computers. This only has to be done once on any computer, and it'll sync your transactions with the mainnet, and update the wallet with your previous transactions. |
|||
|
|||
Once imported, you will access your funds by simply login into the wallet. |
|||
|
|||
#### Q: How do I create a new wallet? |
|||
|
|||
**A:** Select the create wallet button on the login screen and it'll let you browse to a location in which you wish to store your keyfile.p12. Remember to give it a name, and select save. |
|||
|
|||
After that you need to provide the keyfile with authentication, populate the remaining fields and click create. Then the keyfile.p12 will be saved to the location you specified, and you can use that to access your funds through Molly Wallet. |
|||
|
|||
|
|||
I will organize the questions based on the OS the wallet is installed upon. |
|||
|
|||
## Windows |
|||
|
|||
#### Q: Why am I getting an error when sending transaction? |
|||
|
|||
**A:** This is most likely because you've used an older testnet build of the wallet with testnet artifacts interfering with your mainnet wallet. In order to fix this you need to remove the `.dag` folder that is located in your `C:/Users/<username>/` directory. Once done, feel free to import your wallet again and you should be good to go. |
|||
|
|||
#### Q: I am getting `Unable to detect your Java path, make sure Java has been installed` when trying to login or create/import a wallet. |
|||
|
|||
***A:*** This is either because [OpenJDK v9](https://java.com/) and [JRE](https://www.oracle.com/java/technologies/javase-jre8-downloads.html) hasn't been installed, or because the enviornment paths haven't been set up correctly. |
|||
|
|||
For Molly Wallet to be able to detect the installation directory of Java, the `JAVA_HOME` enviornmental variable needs t |
|||
o be set. The Java path also has to be included in the `Path` variable. |
|||
|
|||
Search for enviornment variables in windows search and select *Edit the system enviornment variables*, then set the JAVA |
|||
paths like in the below image. |
|||
|
|||
[![env](https://i.ibb.co/Br1M31s/envvars.png)](https://constellationnetwork.io/technology/molly-wallet/) |
|||
|
|||
#### Q: How to open a ``.rar`` file? |
|||
|
|||
[![IMAGE ALT TEXT HERE](https://img.youtube.com/vi/dr0g_Ux7_8M/0.jpg)](https://www.youtube.com/watch?v=dr0g_Ux7_8M) |
|||
|
|||
#### Q: I am running Windows 7 (or earlier) - can I run Molly Wallet? |
|||
|
|||
**A:** The answer is maybe. It's not officially supported, so probably not. |
|||
|
|||
## MacOS |
|||
|
|||
#### Q: Why am I getting an error when sending transaction? |
|||
|
|||
**A:** This is most likely because you've used an older testnet build of the wallet with testnet artifacts interfering with your mainnet wallet. In order to fix this you need to remove the `.dag` folder that is located in your `$HOME` (if you don't know what this is, see [this article](https://www.cnet.com/how-to/how-to-find-your-macs-home-folder-and-add-it-to-finder/)) path. Once done, feel free to import your wallet again and you should be good to go. |
|||
|
|||
#### Q: Why am I getting `The application 'Molly - Constellation Desktop Wallet` can't be opened? |
|||
|
|||
![env](https://i.ibb.co/VWw30HN/a123555f-0881-4ae8-9b1d-7dd36d4d6802.jpg) |
|||
|
|||
|
|||
**A:** This is because the application is compressed and archived. You need to use a software called [Unarchiver](https://theunarchiver.com/). |
|||
|
|||
After you've downloaded that, right click on the .zip file and select "open with" and then choose "Unarchiver" instead of the default program. It'll unzip and you'll be able to run it. |
|||
|
|||
#### Q: Why is my Molly Wallet download not downloaded as a .zip file? |
|||
|
|||
**A:** On some systems, Safari will automatically unzip the contents. We do not want that, we want to leverage the [Unarchiver](https://theunarchiver.com/). Here's a guide [how to disable automatic unzipping](https://www.addictivetips.com/mac-os/stop-automatically-unzipping-files-in-safari/) of files in Safari. Once that has been disabled, redownload Molly Wallet from the [official website](https://constellationnetwork.io/technology/molly-wallet/) and use [Unarchiver](https://theunarchiver.com/) to extract the contents. |
|||
|
|||
## Linux |
|||
#### Q: Why does it say `Alias not found` when trying to import/login/create wallet? |
|||
|
|||
**A:** This is probably due to Java missing on the system. For now, I've only implemented means of detecting that on Windows. So please, if you're using a debian based distribution, download `openjdk-8` from aptitude. |
|||
|
|||
#### Q: Why am I getting an error when sending transaction? |
|||
|
|||
**A:** This is most likely because you've used an older testnet build of the wallet with testnet artifacts interfering with your mainnet wallet. In order to fix this you need to remove the `.dag` folder that is located in your `$HOME` path. Once done, feel free to import your wallet again and you should be good to go. |
|||
|
|||
## Java |
|||
|
|||
#### Q: Why is Java needed? |
|||
|
|||
**A:** The wallet binary that I'm integrating Molly Wallet against has been built by the Constellation Core team in a programming language called *Scala*. This is a functional programming language that runs on JVM (Java virtual machine). Thus Java becomes a dependency. |
|||
|
@ -1,5 +0,0 @@ |
|||
module.exports = { |
|||
presets: [ |
|||
'@vue/app' |
|||
] |
|||
} |
@ -1,82 +0,0 @@ |
|||
{ |
|||
"name": "my_project", |
|||
"author": "vito<gralle89@gmail.com>", |
|||
"private": true, |
|||
"scripts": { |
|||
"serve": "vue-cli-service serve", |
|||
"build": "vue-cli-service build", |
|||
"lint": "vue-cli-service lint" |
|||
}, |
|||
"dependencies": { |
|||
"@types/vuelidate": "^0.7.13", |
|||
"@wailsapp/runtime": "^1.0.11", |
|||
"babel-helper-vue-jsx-merge-props": "^2.0.3", |
|||
"bootstrap": "^4.4.1", |
|||
"chartist": "^0.11.4", |
|||
"core-js": "^2.6.11", |
|||
"es6-promise": "^4.2.8", |
|||
"jquery": "^3.5.0", |
|||
"node-sass": "^4.13.1", |
|||
"popper.js": "^1.16.1", |
|||
"sass-loader": "^8.0.2", |
|||
"serialize-javascript": "^2.1.2", |
|||
"update": "^0.7.4", |
|||
"vue": "^2.6.11", |
|||
"vue-clickaway": "^2.2.2", |
|||
"vue-js-toggle-button": "^1.3.3", |
|||
"vue-loading-spinner": "^1.0.11", |
|||
"vue-notifyjs": "^0.4.3", |
|||
"vue-progressbar": "^0.7.5", |
|||
"vue-router": "^3.1.6", |
|||
"vue-select": "^3.10.3", |
|||
"vue-spinner": "^1.0.3", |
|||
"vue-spinner-component": "^1.0.5", |
|||
"idle-vue": "^2.0.5", |
|||
"sweetalert2": "^9.10.12", |
|||
"@sweetalert2/theme-bootstrap-4": "^3.1.4", |
|||
"vuelidate": "^0.7.5", |
|||
"vuex": "^3.1.3", |
|||
"webpack": "^4.42.0", |
|||
"yarn": "^1.22.4" |
|||
}, |
|||
"devDependencies": { |
|||
"@vue/cli-plugin-babel": "^3.12.1", |
|||
"@vue/cli-plugin-eslint": "^3.12.1", |
|||
"@vue/cli-service": "^4.3.1", |
|||
"acorn": "^6.4.1", |
|||
"babel-eslint": "^10.1.0", |
|||
"eslint": "^5.8.0", |
|||
"eslint-plugin-vue": "^5.0.0", |
|||
"eventsource-polyfill": "^0.9.6", |
|||
"json-loader": "^0.5.7", |
|||
"kind-of": "^6.0.3", |
|||
"minimist": "^1.2.3", |
|||
"prerender-spa-plugin": "^3.4.0", |
|||
"vue-template-compiler": "^2.6.11", |
|||
"webpack-hot-middleware": "^2.24.3" |
|||
}, |
|||
"eslintConfig": { |
|||
"root": true, |
|||
"env": { |
|||
"node": true |
|||
}, |
|||
"extends": [ |
|||
"plugin:vue/essential", |
|||
"eslint:recommended" |
|||
], |
|||
"rules": {}, |
|||
"parserOptions": { |
|||
"parser": "babel-eslint" |
|||
} |
|||
}, |
|||
"postcss": { |
|||
"plugins": { |
|||
"autoprefixer": {} |
|||
} |
|||
}, |
|||
"browserslist": [ |
|||
"> 1%", |
|||
"last 2 versions", |
|||
"not ie <= 8" |
|||
] |
|||
} |
@ -1 +0,0 @@ |
|||
3efa6e8b927d5ad25a172f56fca8cf6a |
@ -1,303 +0,0 @@ |
|||
<template> |
|||
<div :class="darkMode ? 'theme--dark' : 'theme--light'"> |
|||
<vue-progress-bar></vue-progress-bar> |
|||
<div :class="{'nav-open': $sidebar.showSidebar}"> |
|||
<notifications></notifications> |
|||
<router-view></router-view> |
|||
</div> |
|||
<page-overlay text="Applying Update. Please wait..." :isActive="overlay" /> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import {mapState} from 'vuex' |
|||
import ErrorNotification from "./pages/Notifications/ErrorMessage"; |
|||
import WarningNotification from "./pages/Notifications/Warning"; |
|||
import SuccessNotification from "./pages/Notifications/Success"; |
|||
import NewRelease from "./pages/Notifications/NewRelease"; |
|||
import Swal from "sweetalert2/dist/sweetalert2"; |
|||
|
|||
export default { |
|||
components: { |
|||
}, |
|||
computed: { |
|||
...mapState('app', ['isLoggedIn', 'downloading']), |
|||
...mapState('wallet', ['darkMode']) |
|||
}, |
|||
data() { |
|||
return { |
|||
overlay: false |
|||
}; |
|||
}, |
|||
onIdle() { |
|||
let timerInterval, closeInSeconds = 10 |
|||
const swalPopup = Swal.mixin({ |
|||
customClass: { |
|||
container: this.darkMode |
|||
? "theme--dark" |
|||
: "theme--light" |
|||
} |
|||
}); |
|||
if (this.isLoggedIn) { |
|||
swalPopup.fire({ |
|||
title: "You have been idle for 5 minutes.", |
|||
html: "To keep your Molly wallet safe from unauthorised access it will automatically logout in <b>10</b> seconds", |
|||
showConfirmButton: false, |
|||
showCancelButton: false, |
|||
timer: closeInSeconds * 1000, |
|||
timerProgressBar: true, |
|||
onBeforeOpen: () => { |
|||
Swal.showLoading(); |
|||
timerInterval = setInterval(() => { |
|||
closeInSeconds--; |
|||
const content = Swal.getContent(); |
|||
if (content) { |
|||
const b = content.querySelector('b'); |
|||
if (b) { |
|||
b.textContent = closeInSeconds; |
|||
} |
|||
} |
|||
}, 1000) |
|||
}, |
|||
onClose: () => { |
|||
clearInterval(timerInterval) |
|||
} |
|||
}).then(() => { |
|||
if (this.$store.state.idleVue.isIdle) { |
|||
window.backend.WalletApplication.LogOut().then(txFinishedState => { |
|||
if (txFinishedState) { |
|||
this.logout(); |
|||
} |
|||
}), (this.random = "1"); |
|||
} |
|||
}); |
|||
} |
|||
}, |
|||
mounted() { |
|||
// Backend Errors |
|||
window.wails.Events.On("error_handling", (m, err) => { |
|||
this.$store.commit('app/setErrorMessage', m + err); |
|||
this.$notifications.clear(); |
|||
setTimeout(() => { |
|||
this.$notifications.clear(); |
|||
}, 60000); |
|||
this.$notify({ |
|||
component: ErrorNotification, |
|||
timeout: 50000, |
|||
icon: "fa fa-times", |
|||
horizontalAlign: "right", |
|||
verticalAlign: "top", |
|||
type: "danger", |
|||
onClick: () => { |
|||
this.$notifications.clear(); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
window.wails.Events.On("warning", m => { |
|||
this.$store.commit('app/setWarningMessage', m); |
|||
this.$notifications.clear(); |
|||
setTimeout(() => { |
|||
this.$notifications.clear(); |
|||
}, 60000); |
|||
this.$notify({ |
|||
component: WarningNotification, |
|||
timeout: 50000, |
|||
icon: "fa fa-times", |
|||
horizontalAlign: "right", |
|||
verticalAlign: "top", |
|||
type: "warning", |
|||
onClick: () => { |
|||
this.$notifications.clear(); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
window.wails.Events.On("success", m => { |
|||
this.$store.commit('app/setSuccessMessage', m); |
|||
this.$notifications.clear(); |
|||
setTimeout(() => { |
|||
this.$notifications.clear(); |
|||
}, 60000); |
|||
this.$notify({ |
|||
component: SuccessNotification, |
|||
timeout: 50000, |
|||
icon: "fa fa-check", |
|||
horizontalAlign: "right", |
|||
verticalAlign: "top", |
|||
type: "success", |
|||
onClick: () => { |
|||
this.$notifications.clear(); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
window.wails.Events.On("new_release", m => { |
|||
this.$store.commit('app/setNewRelease', m); |
|||
var self = this; |
|||
this.$notifications.clear(); |
|||
this.$notify({ |
|||
component: NewRelease, |
|||
timeout: 500000, |
|||
icon: "fa fa-info", |
|||
horizontalAlign: "right", |
|||
verticalAlign: "bottom", |
|||
type: "info", |
|||
onClick: () => { |
|||
const swalPopup = Swal.mixin({ |
|||
customClass: { |
|||
container: this.darkMode |
|||
? "theme--dark" |
|||
: "theme--light" |
|||
} |
|||
|
|||
}); |
|||
|
|||
swalPopup.fire({ |
|||
title: "Update Molly Wallet", |
|||
html: "Do you want to update your Molly Wallet? Selecting update will download the latest build and apply the update. <br><br> <b>The application will restart once update is complete. </b>", |
|||
showCloseButton: true, |
|||
showCancelButton: true, |
|||
focusConfirm: false, |
|||
confirmButtonText: |
|||
'<i class="fa fa-thumbs-up"></i> <b>Update</b>', |
|||
confirmButtonAriaLabel: "Text", |
|||
cancelButtonText: |
|||
'Cancel', |
|||
cancelButtonAriaLabel: "Cancel" |
|||
}).then(result => { |
|||
if (result.value) { |
|||
self.$Progress.start(2000); |
|||
self.overlay = true; |
|||
window.backend.WalletApplication.UpdateMolly() |
|||
self.$notifications.clear(); |
|||
} |
|||
}); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
window.wails.Events.On("login_error", (m, err) => { |
|||
this.$store.commit('app/setLoginErrorMessage', m); |
|||
this.$store.commit('app/setDisplayLoginError', err); |
|||
setTimeout(() => { |
|||
this.$store.commit('app/setDisplayLoginError', false); |
|||
}, 10000); |
|||
}); |
|||
|
|||
// Transactions.vue sockets |
|||
window.wails.Events.On("update_tx_history", txHistoryFull => { |
|||
if (Object.entries(txHistoryFull).length != 0) { |
|||
this.$store.commit({type: 'transaction/updateFullTxHistory', txHistoryFull}); |
|||
} |
|||
}); |
|||
window.wails.Events.On("tx_in_transit", txFinished => { |
|||
this.$store.commit('transaction/setTxFinished', txFinished); |
|||
}); |
|||
window.wails.Events.On("new_transaction", txObject => { |
|||
this.$store.commit("transaction/updateTxHistory", txObject); |
|||
}); |
|||
window.wails.Events.On("tx_pending", txStatus => { |
|||
this.$store.commit("transaction/updateTxStatus", txStatus); |
|||
}); |
|||
|
|||
window.wails.Events.On("downloading", (filename, size) => { |
|||
if (this.downloading.filename !== filename) { |
|||
this.$store.commit('app/setDownloadFileName', filename); |
|||
} |
|||
this.$store.commit('app/setDownloadFileSize', size); |
|||
}); |
|||
|
|||
// Login.vue sockets |
|||
// window.wails.Events.On("registeredLogin", event => {}); |
|||
|
|||
// Dashboard.vue sockets |
|||
window.wails.Events.On("token", (amount, available, total) => { |
|||
this.$store.commit('wallet/setTokenAmount', amount); |
|||
this.$store.commit('wallet/setAvailableBalance', available); |
|||
this.$store.commit('wallet/setTotalBalance', total); |
|||
}); |
|||
window.wails.Events.On("blocks", number => { |
|||
this.$store.commit('dashboard/setBlocks', number); |
|||
}); |
|||
window.wails.Events.On("totalValue", (currency, value) => { |
|||
this.$store.commit('wallet/setCurrency', currency); |
|||
this.$store.commit('wallet/setTotalValue', value); |
|||
}); |
|||
window.wails.Events.On("token_counter", count => { |
|||
this.$store.commit('dashboard/setTokenCounter', count); |
|||
}); |
|||
window.wails.Events.On("value_counter", valueCount => { |
|||
this.$store.commit('dashboard/setValueCounter', valueCount); |
|||
}); |
|||
window.wails.Events.On("block_counter", blockCount => { |
|||
this.$store.commit('dashboard/setBlockCounter', blockCount); |
|||
}); |
|||
window.wails.Events.On("chart_counter", pieChartCount => { |
|||
this.$store.commit('dashboard/setChartCounter', pieChartCount); |
|||
}); |
|||
window.wails.Events.On("node_stats", (series, labels) => { |
|||
if (Object.entries(series).length != 0 && |
|||
Object.entries(labels).length != 0) { |
|||
this.$store.commit({type: 'dashboard/setNodeOnlineChart', series, labels}); |
|||
} |
|||
}); |
|||
window.wails.Events.On("tx_stats", (seriesOne, seriesTwo, labels) => { |
|||
if (Object.entries(seriesOne).length != 0 && |
|||
Object.entries(seriesTwo).length != 0 && |
|||
Object.entries(labels).length != 0) { |
|||
this.$store.commit({type: 'dashboard/setTransactionStatsChart', seriesOne, seriesTwo, labels}); |
|||
} |
|||
}); |
|||
window.wails.Events.On("network_stats", (seriesOne, seriesTwo, labels) => { |
|||
if (Object.entries(seriesOne).length != 0 && |
|||
Object.entries(seriesTwo).length != 0 && |
|||
Object.entries(labels).length != 0) { |
|||
this.$store.commit({type: 'dashboard/setNetworkStatsChart', seriesOne, seriesTwo, labels}); |
|||
} |
|||
}); |
|||
|
|||
// Settings.vue sockets |
|||
window.wails.Events.On("wallet_keys", address => { |
|||
this.$store.commit('wallet/setAddress', address); |
|||
}); |
|||
} |
|||
}; |
|||
</script> |
|||
|
|||
<style lang="scss"> |
|||
.vue-notifyjs.notifications { |
|||
.alert { |
|||
z-index: 10000; |
|||
font-size: 0.875rem; |
|||
} |
|||
.alert[data-notify="container"] { |
|||
width: 21.875rem; |
|||
} |
|||
.alert-icon { |
|||
margin-left: -0.5em; |
|||
margin-top: -0.5em; |
|||
} |
|||
.list-move { |
|||
transition: transform 0.3s, opacity 0.4s; |
|||
} |
|||
.list-item { |
|||
display: inline-block; |
|||
margin-right: 0.625em; |
|||
} |
|||
.list-enter-active { |
|||
transition: transform 0.2s ease-in, opacity 0.4s ease-in; |
|||
} |
|||
.list-leave-active { |
|||
transition: transform 1s ease-out, opacity 0.4s ease-out; |
|||
} |
|||
.list-enter { |
|||
opacity: 0; |
|||
transform: scale(1.1); |
|||
} |
|||
.list-leave-to { |
|||
opacity: 0; |
|||
transform: scale(1.2, 0.7); |
|||
} |
|||
} |
|||
</style> |
@ -1,202 +0,0 @@ |
|||
|
|||
Apache License |
|||
Version 2.0, January 2004 |
|||
http://www.apache.org/licenses/ |
|||
|
|||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION |
|||
|
|||
1. Definitions. |
|||
|
|||
"License" shall mean the terms and conditions for use, reproduction, |
|||
and distribution as defined by Sections 1 through 9 of this document. |
|||
|
|||
"Licensor" shall mean the copyright owner or entity authorized by |
|||
the copyright owner that is granting the License. |
|||
|
|||
"Legal Entity" shall mean the union of the acting entity and all |
|||
other entities that control, are controlled by, or are under common |
|||
control with that entity. For the purposes of this definition, |
|||
"control" means (i) the power, direct or indirect, to cause the |
|||
direction or management of such entity, whether by contract or |
|||
otherwise, or (ii) ownership of fifty percent (50%) or more of the |
|||
outstanding shares, or (iii) beneficial ownership of such entity. |
|||
|
|||
"You" (or "Your") shall mean an individual or Legal Entity |
|||
exercising permissions granted by this License. |
|||
|
|||
"Source" form shall mean the preferred form for making modifications, |
|||
including but not limited to software source code, documentation |
|||
source, and configuration files. |
|||
|
|||
"Object" form shall mean any form resulting from mechanical |
|||
transformation or translation of a Source form, including but |
|||
not limited to compiled object code, generated documentation, |
|||
and conversions to other media types. |
|||
|
|||
"Work" shall mean the work of authorship, whether in Source or |
|||
Object form, made available under the License, as indicated by a |
|||
copyright notice that is included in or attached to the work |
|||
(an example is provided in the Appendix below). |
|||
|
|||
"Derivative Works" shall mean any work, whether in Source or Object |
|||
form, that is based on (or derived from) the Work and for which the |
|||
editorial revisions, annotations, elaborations, or other modifications |
|||
represent, as a whole, an original work of authorship. For the purposes |
|||
of this License, Derivative Works shall not include works that remain |
|||
separable from, or merely link (or bind by name) to the interfaces of, |
|||
the Work and Derivative Works thereof. |
|||
|
|||
"Contribution" shall mean any work of authorship, including |
|||
the original version of the Work and any modifications or additions |
|||
to that Work or Derivative Works thereof, that is intentionally |
|||
submitted to Licensor for inclusion in the Work by the copyright owner |
|||
or by an individual or Legal Entity authorized to submit on behalf of |
|||
the copyright owner. For the purposes of this definition, "submitted" |
|||
means any form of electronic, verbal, or written communication sent |
|||
to the Licensor or its representatives, including but not limited to |
|||
communication on electronic mailing lists, source code control systems, |
|||
and issue tracking systems that are managed by, or on behalf of, the |
|||
Licensor for the purpose of discussing and improving the Work, but |
|||
excluding communication that is conspicuously marked or otherwise |
|||
designated in writing by the copyright owner as "Not a Contribution." |
|||
|
|||
"Contributor" shall mean Licensor and any individual or Legal Entity |
|||
on behalf of whom a Contribution has been received by Licensor and |
|||
subsequently incorporated within the Work. |
|||
|
|||
2. Grant of Copyright License. Subject to the terms and conditions of |
|||
this License, each Contributor hereby grants to You a perpetual, |
|||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
|||
copyright license to reproduce, prepare Derivative Works of, |
|||
publicly display, publicly perform, sublicense, and distribute the |
|||
Work and such Derivative Works in Source or Object form. |
|||
|
|||
3. Grant of Patent License. Subject to the terms and conditions of |
|||
this License, each Contributor hereby grants to You a perpetual, |
|||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
|||
(except as stated in this section) patent license to make, have made, |
|||
use, offer to sell, sell, import, and otherwise transfer the Work, |
|||
where such license applies only to those patent claims licensable |
|||
by such Contributor that are necessarily infringed by their |
|||
Contribution(s) alone or by combination of their Contribution(s) |
|||
with the Work to which such Contribution(s) was submitted. If You |
|||
institute patent litigation against any entity (including a |
|||
cross-claim or counterclaim in a lawsuit) alleging that the Work |
|||
or a Contribution incorporated within the Work constitutes direct |
|||
or contributory patent infringement, then any patent licenses |
|||
granted to You under this License for that Work shall terminate |
|||
as of the date such litigation is filed. |
|||
|
|||
4. Redistribution. You may reproduce and distribute copies of the |
|||
Work or Derivative Works thereof in any medium, with or without |
|||
modifications, and in Source or Object form, provided that You |
|||
meet the following conditions: |
|||
|
|||
(a) You must give any other recipients of the Work or |
|||
Derivative Works a copy of this License; and |
|||
|
|||
(b) You must cause any modified files to carry prominent notices |
|||
stating that You changed the files; and |
|||
|
|||
(c) You must retain, in the Source form of any Derivative Works |
|||
that You distribute, all copyright, patent, trademark, and |
|||
attribution notices from the Source form of the Work, |
|||
excluding those notices that do not pertain to any part of |
|||
the Derivative Works; and |
|||
|
|||
(d) If the Work includes a "NOTICE" text file as part of its |
|||
distribution, then any Derivative Works that You distribute must |
|||
include a readable copy of the attribution notices contained |
|||
within such NOTICE file, excluding those notices that do not |
|||
pertain to any part of the Derivative Works, in at least one |
|||
of the following places: within a NOTICE text file distributed |
|||
as part of the Derivative Works; within the Source form or |
|||
documentation, if provided along with the Derivative Works; or, |
|||
within a display generated by the Derivative Works, if and |
|||
wherever such third-party notices normally appear. The contents |
|||
of the NOTICE file are for informational purposes only and |
|||
do not modify the License. You may add Your own attribution |
|||
notices within Derivative Works that You distribute, alongside |
|||
or as an addendum to the NOTICE text from the Work, provided |
|||
that such additional attribution notices cannot be construed |
|||
as modifying the License. |
|||
|
|||
You may add Your own copyright statement to Your modifications and |
|||
may provide additional or different license terms and conditions |
|||
for use, reproduction, or distribution of Your modifications, or |
|||
for any such Derivative Works as a whole, provided Your use, |
|||
reproduction, and distribution of the Work otherwise complies with |
|||
the conditions stated in this License. |
|||
|
|||
5. Submission of Contributions. Unless You explicitly state otherwise, |
|||
any Contribution intentionally submitted for inclusion in the Work |
|||
by You to the Licensor shall be under the terms and conditions of |
|||
this License, without any additional terms or conditions. |
|||
Notwithstanding the above, nothing herein shall supersede or modify |
|||
the terms of any separate license agreement you may have executed |
|||
with Licensor regarding such Contributions. |
|||
|
|||
6. Trademarks. This License does not grant permission to use the trade |
|||
names, trademarks, service marks, or product names of the Licensor, |
|||
except as required for reasonable and customary use in describing the |
|||
origin of the Work and reproducing the content of the NOTICE file. |
|||
|
|||
7. Disclaimer of Warranty. Unless required by applicable law or |
|||
agreed to in writing, Licensor provides the Work (and each |
|||
Contributor provides its Contributions) on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or |
|||
implied, including, without limitation, any warranties or conditions |
|||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A |
|||
PARTICULAR PURPOSE. You are solely responsible for determining the |
|||
appropriateness of using or redistributing the Work and assume any |
|||
risks associated with Your exercise of permissions under this License. |
|||
|
|||
8. Limitation of Liability. In no event and under no legal theory, |
|||
whether in tort (including negligence), contract, or otherwise, |
|||
unless required by applicable law (such as deliberate and grossly |
|||
negligent acts) or agreed to in writing, shall any Contributor be |
|||
liable to You for damages, including any direct, indirect, special, |
|||
incidental, or consequential damages of any character arising as a |
|||
result of this License or out of the use or inability to use the |
|||
Work (including but not limited to damages for loss of goodwill, |
|||
work stoppage, computer failure or malfunction, or any and all |
|||
other commercial damages or losses), even if such Contributor |
|||
has been advised of the possibility of such damages. |
|||
|
|||
9. Accepting Warranty or Additional Liability. While redistributing |
|||
the Work or Derivative Works thereof, You may choose to offer, |
|||
and charge a fee for, acceptance of support, warranty, indemnity, |
|||
or other liability obligations and/or rights consistent with this |
|||
License. However, in accepting such obligations, You may act only |
|||
on Your own behalf and on Your sole responsibility, not on behalf |
|||
of any other Contributor, and only if You agree to indemnify, |
|||
defend, and hold each Contributor harmless for any liability |
|||
incurred by, or claims asserted against, such Contributor by reason |
|||
of your accepting any such warranty or additional liability. |
|||
|
|||
END OF TERMS AND CONDITIONS |
|||
|
|||
APPENDIX: How to apply the Apache License to your work. |
|||
|
|||
To apply the Apache License to your work, attach the following |
|||
boilerplate notice, with the fields enclosed by brackets "[]" |
|||
replaced with your own identifying information. (Don't include |
|||
the brackets!) The text should be enclosed in the appropriate |
|||
comment syntax for the file format. We also recommend that a |
|||
file or class name and description of purpose be included on the |
|||
same "printed page" as the copyright notice for easier |
|||
identification within third-party archives. |
|||
|
|||
Copyright [yyyy] [name of copyright owner] |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
Before Width: | Height: | Size: 676 KiB |
Before Width: | Height: | Size: 141 KiB |
Before Width: | Height: | Size: 820 KiB |