├── .gitignore ├── LICENSE ├── README.md ├── config ├── config.go ├── config_test.go ├── jsonemptypath.go ├── jsonemptypath_test.go ├── jsonpath.go ├── jsonpath_test.go ├── jsonproxy.go ├── jsonproxy_test.go └── ssr_config.go ├── crawler ├── crawler.go └── utils.go ├── geoip └── geoip.go ├── image ├── Removefixed.svg ├── close.png ├── download.svg ├── ic_copy_link.svg └── icon.svg ├── main.go ├── models ├── encrypt.go ├── encrypt_test.go ├── used_amount.go ├── used_amount_test.go ├── users.go └── users_test.go ├── parser ├── invoice.go ├── invoice_test.go ├── parser.go ├── parser_test.go ├── service.go ├── ssrinfo.go ├── ssrnode.go ├── ssrnode_test.go └── testdata │ └── invoice.html ├── pyclient ├── pyssrclient.go ├── pyssrclient_config.go ├── pyssrclient_config_test.go ├── pyssrclient_test.go └── testdata │ ├── test_config.json │ ├── test_default.json │ └── test_empty.json ├── resource.qrc ├── screenshots ├── charts.png ├── invoices.png ├── invoiceview.webp ├── logging.png ├── login.png ├── nodes.png ├── service1.webp ├── service2.webp └── settings.png ├── ssr └── ssr.go ├── urls └── urls.go └── widgets ├── account_item.go ├── charts_dialog.go ├── client_config_widget.go ├── color_label.go ├── config_widget.go ├── data_bridge.go ├── downloader.go ├── invoice_dialog.go ├── invoice_panel.go ├── invoice_view_widget.go ├── login_indicator.go ├── login_widget.go ├── main_widget.go ├── node_model.go ├── notify.go ├── progressbar.go ├── savepath_recorder.go ├── service_panel.go ├── shade_widget.go ├── ssr_node_detail_widget.go ├── ssr_node_info_panel.go ├── ssr_node_select_dialog.go ├── ssr_switch_panel.go ├── ssrclient_config_widget.go ├── summarized_widget.go ├── switch_button.go ├── transparent_button.go ├── used_panel.go ├── utils.go ├── utils_test.go └── widget_utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Qt template 3 | # C++ objects and libs 4 | *.slo 5 | *.lo 6 | *.o 7 | *.a 8 | *.la 9 | *.lai 10 | *.so 11 | *.dll 12 | *.dylib 13 | 14 | # Qt-es 15 | object_script.*.Release 16 | object_script.*.Debug 17 | *_plugin_import.cpp 18 | /.qmake.cache 19 | /.qmake.stash 20 | *.pro.user 21 | *.pro.user.* 22 | *.qbs.user 23 | *.qbs.user.* 24 | *.moc 25 | moc_*.cpp 26 | moc_*.h 27 | qrc_*.cpp 28 | ui_*.h 29 | *.qmlc 30 | *.jsc 31 | Makefile* 32 | *build-* 33 | rcc.cpp 34 | rcc_cgo_* 35 | 36 | # Qt unit tests 37 | target_wrapper.* 38 | 39 | # QtCreator 40 | *.autosave 41 | 42 | # QtCreator Qml 43 | *.qmlproject.user 44 | *.qmlproject.user.* 45 | 46 | # QtCreator CMake 47 | CMakeLists.txt.user* 48 | ### Go template 49 | # Binaries for programs and plugins 50 | *.exe 51 | *.exe~ 52 | 53 | # IDE 54 | .idea/* 55 | 56 | # Auto code gen 57 | widgets/moc.* 58 | widgets/moc_*.* 59 | widgets/rcc.cpp 60 | widgets/rcc_cgo_*.go 61 | 62 | # Test binary, build with `go test -c` 63 | *.test 64 | 65 | # Output of the go coverage tool, specifically when used with LiteIDE 66 | *.out 67 | 68 | # go build result 69 | schannel-qt5 70 | 71 | emoji-flags/ 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2019 apocelipes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## schannel-qt5 2 | A QT based GUI client for [schannel](https://schannel.net/) - written in Golang 3 | 4 | ### Features: 5 | - clear data usage display 6 | - quickly view service information 7 | - supports lots of user settings 8 | - simply & easy to use 9 | - freely configure the ssr client program 10 | - use charts to show data more clearly 11 | 12 | ### Installation 13 | At first we need to install [therecipe/qt](https://github.com/therecipe/qt) 14 | 15 | Then: 16 | ```bash 17 | go get -u github.com/astaxie/beego/orm 18 | go get -u github.com/mattn/go-sqlite3 19 | go get -u github.com/PuerkitoBio/goquery 20 | cd $GOPATH/src 21 | git clone 'https://github.com/apocelipes/schannel-qt5' 22 | # install country flags info 23 | git clone 'https://github.com/matiassingers/emoji-flags' schannel-qt5/emoji-flags 24 | cd schannel-qt5/widgets 25 | qtmoc && qtrcc 26 | cd .. && go build 27 | ``` 28 | 29 | Note that schannel-qt5 needs golang >= go1.12. 30 | 31 | Because of the flaw in QFont on x11, schannel-qt5 can't set its own font without fontconfig, so you need to install the font "NotoColorEmoji" to support emoji. 32 | 33 | Now you can enjoy schannel-qt5! 34 | 35 | ### Screenshots 36 | login: 37 | 38 | ![login](screenshots/login.png) 39 | 40 | logging: 41 | 42 | ![logging](screenshots/logging.png) 43 | 44 | invoices view: 45 | 46 | ![invoices](screenshots/invoices.png) 47 | 48 | ![invoiceview](screenshots/invoiceview.webp) 49 | 50 | select nodes: 51 | 52 | ![nodes](screenshots/nodes.png) 53 | 54 | service info & client switch: 55 | 56 | ![client-turn-off](screenshots/service1.webp) 57 | 58 | ![client-turn-off](screenshots/service2.webp) 59 | 60 | user settings: 61 | 62 | ![settings](screenshots/settings.png) 63 | 64 | data charts: 65 | 66 | ![charts](screenshots/charts.png) 67 | 68 | ### important configurations: 69 | - `ssrclient.json`: Configure the behavior of the ssr client. 70 | - `~/.local/share/schannel-qt5.json`: Configure the behavior of the schannel-qt5. 71 | - `~/.local/share/schannel-users.db`: Store encrypted user information and traffic usage records (traffic records for chart display). 72 | - `~/.local/share/data/schannel-qt5/GeoIP/`: Store the GeoIP database. 73 | 74 | ### Options in schannel-qt5.json: 75 | - `proxy_url`: The proxy server address used by schannel-qt5, or empty if you don't use a proxy. 76 | - `log_file`: schannel-qt5's log file, uses stdout if it is empty. 77 | - `ssr_node_config_path`: The path of a ssr node config file. 78 | - `ssr_client_config_path"`: The path of ssr client config file. 79 | - `ssr_bin`: The path of ssr client bin. 80 | 81 | ### Todo: 82 | - support system tray icon 83 | - more clearly document 84 | - more tests 85 | 86 | Welcome feedback questions and submit PRs, 87 | 88 | I am looking forward to working with you. 89 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | const ( 12 | // 默认配置文件路径 13 | configPath = ".local/share/schannel-qt5.json" 14 | ) 15 | 16 | var ( 17 | // ErrHOME 无法查找$HOME 18 | ErrHOME = errors.New("can't find $HOME in your environments") 19 | // ErrNotAbs 路径无法解析为绝对路径 20 | ErrNotAbs = errors.New("path is not an abs path") 21 | ) 22 | 23 | // UserConfig 用户配置 24 | type UserConfig struct { 25 | // client config 26 | Proxy JSONProxy `json:"proxy_url"` 27 | LogFile JSONEmptyPath `json:"log_file"` 28 | 29 | // ssr config 30 | SSRNodeConfigPath JSONPath `json:"ssr_node_config_path"` 31 | SSRClientConfigPath JSONPath `json:"ssr_client_config_path"` 32 | 33 | // ssr client bin path 34 | SSRBin JSONPath `json:"ssr_bin"` 35 | 36 | // ssr client config的实体数据 37 | SSRClientConfig ClientConfig `json:"-"` 38 | } 39 | 40 | // ConfigPath 返回`~`被替换为$HOME的config path 41 | func ConfigPath() (string, error) { 42 | home, err := os.UserHomeDir() 43 | if err != nil { 44 | return "", ErrHOME 45 | } 46 | 47 | return filepath.Join(home, configPath), nil 48 | } 49 | 50 | // StoreConfig 将配置存储进ConfigPath路径的文件 51 | func (u *UserConfig) StoreConfig() error { 52 | storePath, err := ConfigPath() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | f, err := os.OpenFile(storePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664) 58 | if err != nil { 59 | return err 60 | } 61 | defer f.Close() 62 | 63 | data, err := json.MarshalIndent(u, "", "\t") 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if _, err = f.Write(data); err != nil { 69 | return err 70 | } 71 | 72 | clientConfigPath, err := u.SSRClientConfigPath.AbsPath() 73 | if err != nil { 74 | return err 75 | } 76 | if err := u.SSRClientConfig.Store(clientConfigPath); err != nil { 77 | return err 78 | } 79 | 80 | return nil 81 | } 82 | 83 | // LoadConfig 从ConfigPath给出的配置文件路径读出配置 84 | func (u *UserConfig) LoadConfig() error { 85 | loadPath, err := ConfigPath() 86 | if err != nil { 87 | return err 88 | } 89 | 90 | f, err := os.Open(loadPath) 91 | if err != nil { 92 | return err 93 | } 94 | defer f.Close() 95 | 96 | data, err := ioutil.ReadAll(f) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | if err = json.Unmarshal(data, u); err != nil { 102 | return err 103 | } 104 | 105 | clientConfigPath, err := u.SSRClientConfigPath.AbsPath() 106 | if err != nil { 107 | return err 108 | } 109 | if err := u.SSRClientConfig.Load(clientConfigPath); err != nil { 110 | return err 111 | } 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "encoding/json" 7 | "os" 8 | ) 9 | 10 | func TestConfigPath(t *testing.T) { 11 | testData := []*struct { 12 | // 设置环境变量HOME的值 13 | home string 14 | res string 15 | }{ 16 | { 17 | home: "/home/test", 18 | res: "/home/test/" + configPath, 19 | }, 20 | { 21 | home: "/home/test/", 22 | res: "/home/test/" + configPath, 23 | }, 24 | { 25 | home: "/home/用户1/", 26 | res: "/home/用户1/" + configPath, 27 | }, 28 | } 29 | 30 | for _, v := range testData { 31 | os.Clearenv() 32 | err := os.Setenv("HOME", v.home) 33 | if err != nil { 34 | t.Fatalf("无法设置$HOME: %v\n", err) 35 | } 36 | res, err := ConfigPath() 37 | if err != nil { 38 | t.Fatalf("获取Config Path错误:%v\n", err) 39 | } 40 | if v.res != res { 41 | format := "不正确的Config Path:\n\twant: %s\n\thave: %v\n" 42 | t.Errorf(format, v.res, res) 43 | } 44 | } 45 | } 46 | 47 | func TestMarshalUserConf(t *testing.T) { 48 | u := new(UserConfig) 49 | u.SSRNodeConfigPath.Data = "/tmp/testing/t.json" 50 | u.SSRClientConfigPath.Data = "/tmp/testing/client.json" 51 | u.SSRBin.Data = "/tmp/testing/a.out" 52 | u.LogFile.Data = "/tmp/a.log" 53 | 54 | data, err := json.MarshalIndent(u, "", "\t") 55 | if err != nil { 56 | t.Error(err) 57 | } 58 | t.Log(string(data)) 59 | } 60 | 61 | func TestUnmarshalUserConf(t *testing.T) { 62 | u := new(UserConfig) 63 | // 需要解析成config的原始数据 64 | data := `{"ssr_node_config_path":"/tmp/t.json","ssr_bin":"/tmp/a.out","log_file":"/tmp/a.log","ssr_client_config_path":"/tmp/c.json"}` 65 | if err := json.Unmarshal([]byte(data), u); err != nil { 66 | t.Error(err) 67 | } 68 | t.Log(*u) 69 | } 70 | -------------------------------------------------------------------------------- /config/jsonemptypath.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "strings" 4 | 5 | // 允许设置路径为空的JSONPath 6 | type JSONEmptyPath struct { 7 | JSONPath 8 | } 9 | 10 | func (ep *JSONEmptyPath) UnmarshalJSON(b []byte) error { 11 | if string(b) == `""` { 12 | ep.Data = string(b) 13 | ep.Data = strings.TrimSuffix(ep.Data, `"`) 14 | ep.Data = strings.TrimPrefix(ep.Data, `"`) 15 | return nil 16 | } 17 | 18 | return ep.JSONPath.UnmarshalJSON(b) 19 | } 20 | 21 | func (ep *JSONEmptyPath) MarshalJSON() ([]byte, error) { 22 | if ep.Data == "" { 23 | return []byte(`""`), nil 24 | } 25 | 26 | return ep.JSONPath.MarshalJSON() 27 | } 28 | 29 | // 路径数据是否为空 30 | func (ep *JSONEmptyPath) IsEmpty() bool { 31 | return ep.Data == "" 32 | } 33 | -------------------------------------------------------------------------------- /config/jsonemptypath_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "encoding/json" 7 | ) 8 | 9 | // 测试路径json转换 10 | type e struct { 11 | Path *JSONEmptyPath `json:"path"` 12 | } 13 | 14 | func TestJSONEmptyPathMarshalJSON(t *testing.T) { 15 | testData := []*struct { 16 | data *e 17 | success bool 18 | res string 19 | }{ 20 | { 21 | data: &e{ 22 | Path: &JSONEmptyPath{JSONPath{Data: ""}}, 23 | }, 24 | success: true, 25 | res: `{"path":""}`, 26 | }, 27 | { 28 | data: &e{ 29 | Path: &JSONEmptyPath{JSONPath{Data: "/tmp/a"}}, 30 | }, 31 | success: true, 32 | res: `{"path":"/tmp/a"}`, 33 | }, 34 | { 35 | data: &e{ 36 | Path: &JSONEmptyPath{JSONPath{Data: "tmp/a"}}, 37 | }, 38 | success: false, 39 | res: ``, 40 | }, 41 | } 42 | 43 | for _, v := range testData { 44 | res, err := json.Marshal(v.data) 45 | if (err == nil) != v.success { 46 | format := "marshal error:\n\tdata: %s\n\terror: %v\n\twant :%v\n" 47 | t.Errorf(format, v.data.Path, err, v.success) 48 | } else if v.success && string(res) != v.res { 49 | format := "marshal wrong:\n\twant: %s\n\thave: %s\n" 50 | t.Errorf(format, v.res, string(res)) 51 | } 52 | } 53 | } 54 | 55 | func TestJSONEmptyPathUnmarshalJSON(t *testing.T) { 56 | testData := []*struct { 57 | data string 58 | success bool 59 | // 存放解析结果 60 | res *e 61 | // 正确的路径 62 | resPath string 63 | }{ 64 | { 65 | data: `{"path":""}`, 66 | success: true, 67 | res: &e{ 68 | Path: &JSONEmptyPath{JSONPath{}}, 69 | }, 70 | resPath: "", 71 | }, 72 | { 73 | data: `{"path":"/tmp/a"}`, 74 | success: true, 75 | res: &e{ 76 | Path: &JSONEmptyPath{JSONPath{}}, 77 | }, 78 | resPath: "/tmp/a", 79 | }, 80 | { 81 | data: `{"path":"tmp/a"}`, 82 | success: false, 83 | res: &e{ 84 | Path: &JSONEmptyPath{JSONPath{}}, 85 | }, 86 | resPath: "", 87 | }, 88 | } 89 | 90 | for _, v := range testData { 91 | err := json.Unmarshal([]byte(v.data), v.res) 92 | if (err == nil) != v.success { 93 | format := "unmarshal error:\n\tdata: %s\n\terror: %v\n\twant :%v\n" 94 | t.Errorf(format, v.data, err, v.success) 95 | } else if v.success && v.res.Path.String() != v.resPath { 96 | format := "unmarshal wrong:\n\twant: %s\n\thave: %s\n" 97 | t.Errorf(format, v.resPath, v.res.Path) 98 | } 99 | } 100 | } 101 | 102 | func TestJSONEmptyPathIsEmpty(t *testing.T) { 103 | testData := []*struct{ 104 | data string 105 | empty bool 106 | }{ 107 | { 108 | data: "/tmp/a", 109 | empty: false, 110 | }, 111 | { 112 | data: "", 113 | empty: true, 114 | }, 115 | } 116 | 117 | for _,v := range testData { 118 | ep := &JSONEmptyPath{JSONPath{Data: v.data}} 119 | if ep.IsEmpty() != v.empty { 120 | format := "isEmpty wrong:\n\tpath: %s\n\twant: %v\n\thave: %v\n" 121 | t.Errorf(format, v.data, v.empty, ep.IsEmpty()) 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /config/jsonpath.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "strings" 7 | ) 8 | 9 | // JSONPath unmarshal/marshal时验证路径是否为绝对路径 10 | type JSONPath struct { 11 | Data string 12 | } 13 | 14 | func (jp *JSONPath) UnmarshalJSON(b []byte) error { 15 | data := string(b) 16 | // 对于字符串的json值,需要手动去除双引号 17 | data = strings.TrimSuffix(data, "\"") 18 | data = strings.TrimPrefix(data, "\"") 19 | if !strings.HasPrefix(data, "~") && !path.IsAbs(data) { 20 | return ErrNotAbs 21 | } 22 | 23 | jp.Data = data 24 | return nil 25 | } 26 | 27 | func (jp JSONPath) String() string { 28 | return jp.Data 29 | } 30 | 31 | func (jp *JSONPath) MarshalJSON() ([]byte, error) { 32 | if !strings.HasPrefix(jp.Data, "~") && !path.IsAbs(jp.Data) { 33 | return nil, ErrNotAbs 34 | } 35 | 36 | return []byte("\"" + jp.Data + "\""), nil 37 | } 38 | 39 | // AbsPath 返回对应的绝对路径 40 | func (jp *JSONPath) AbsPath() (string, error) { 41 | if path.IsAbs(jp.Data) { 42 | return jp.Data, nil 43 | } 44 | 45 | if !strings.HasPrefix(jp.Data, "~") { 46 | return "", ErrNotAbs 47 | } 48 | 49 | home, err := os.UserHomeDir() 50 | if err != nil { 51 | return "", ErrHOME 52 | } 53 | 54 | return strings.Replace(jp.Data, "~", home, 1), nil 55 | } 56 | -------------------------------------------------------------------------------- /config/jsonpath_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "encoding/json" 7 | "os" 8 | ) 9 | 10 | func TestAbsPath(t *testing.T) { 11 | // 测试绝对路径 12 | abs := JSONPath{"/testing/abs/path"} 13 | data1, err := abs.AbsPath() 14 | if data1 != abs.Data { 15 | t.Error("wrong on abs path: " + data1) 16 | } else if err != nil { 17 | t.Error(err) 18 | } 19 | 20 | // 测试~开头的HOME下路径 21 | os.Clearenv() 22 | err = os.Setenv("HOME", "/home/testing") 23 | if err != nil { 24 | t.Errorf("set $HOME ERROR: %v\n", err) 25 | } 26 | 27 | underHome := JSONPath{"~/testing/path"} 28 | data2, err := underHome.AbsPath() 29 | if data2 != "/home/testing/testing/path" { 30 | t.Error("wrong on home path: " + data2) 31 | } else if err != nil { 32 | t.Error(err) 33 | } 34 | 35 | // 测试非绝对路径 36 | notAbs := JSONPath{"testing/path"} 37 | _, err = notAbs.AbsPath() 38 | if err == nil { 39 | t.Error("ErrNotAbs didn't work") 40 | } 41 | 42 | empty := JSONPath{""} 43 | _, err = empty.AbsPath() 44 | if err == nil { 45 | t.Error("empty string passed") 46 | } 47 | } 48 | 49 | // testing type 50 | type p struct { 51 | Port int64 `json:"port"` 52 | Path JSONPath `json:"path"` 53 | } 54 | 55 | func TestUnmarshalJson(t *testing.T) { 56 | // 测试json数据 57 | data := "{\"port\":12345,\"path\":\"~/.test/\"}" 58 | 59 | j := new(p) 60 | err := json.Unmarshal([]byte(data), j) 61 | if err != nil { 62 | t.Error(err) 63 | } 64 | 65 | if j.Port != 12345 || j.Path.Data != "~/.test/" { 66 | t.Error("unmarshal error") 67 | } 68 | } 69 | 70 | func TestMarshalJson(t *testing.T) { 71 | j := &p{ 72 | Port: 12345, 73 | Path: JSONPath{Data: "~/.test/"}, 74 | } 75 | data, err := json.Marshal(j) 76 | if err != nil { 77 | t.Error(err) 78 | } 79 | 80 | if string(data) != "{\"port\":12345,\"path\":\"~/.test/\"}" { 81 | t.Error("marshal error") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /config/jsonproxy.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | // ErrNotURL 不是合法的URL 11 | ErrNotURL = errors.New("not an valid URL") 12 | 13 | matcher = regexp.MustCompile(`^(http(s)?|socks5)://([\w\-]+\.)+[\w\-]+(:\d+)?(/[\w\- ./?%&=]*)?$`) 14 | ) 15 | 16 | // JSONProxy 验证给定字符串是否是合法的URL 17 | type JSONProxy struct { 18 | Data string 19 | } 20 | 21 | // IsURL 如果p的值是合法的URL,则返回true 22 | func (p *JSONProxy) IsURL() bool { 23 | return matcher.MatchString(p.Data) 24 | } 25 | 26 | func (p JSONProxy) String() string { 27 | return p.Data 28 | } 29 | 30 | func (p *JSONProxy) UnmarshalJSON(b []byte) error { 31 | data := string(b) 32 | // 对于字符串的json值,需要手动去除双引号 33 | data = strings.TrimSuffix(data, "\"") 34 | data = strings.TrimPrefix(data, "\"") 35 | p.Data = data 36 | 37 | // 允许""表示不使用proxy 38 | if !p.IsURL() && p.Data != "" { 39 | return ErrNotURL 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func (p *JSONProxy) MarshalJSON() ([]byte, error) { 46 | if !p.IsURL() && p.Data != "" { 47 | return nil, ErrNotURL 48 | } 49 | 50 | return []byte("\"" + p.Data + "\""), nil 51 | } 52 | -------------------------------------------------------------------------------- /config/jsonproxy_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "testing" 4 | 5 | func TestIsURL(t *testing.T) { 6 | testData := []struct { 7 | data string 8 | res bool 9 | }{ 10 | { 11 | data: "http://example.com", 12 | res: true, 13 | }, 14 | { 15 | data: "https://example.com:8080", 16 | res: true, 17 | }, 18 | { 19 | data: "socks5://127.0.0.1:8000/", 20 | res: true, 21 | }, 22 | { 23 | data: "ftp://test.org/", 24 | res: false, 25 | }, 26 | { 27 | data: "127.0.0.1:1025/", 28 | res: false, 29 | }, 30 | { 31 | data: "https://socks5://www.test.com/", 32 | res: false, 33 | }, 34 | } 35 | 36 | for _, v := range testData { 37 | proxy := JSONProxy{v.data} 38 | if proxy.IsURL() != v.res { 39 | t.Errorf("failed with %s\n", v.data) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /config/ssr_config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // ClientConfigGetter 获取ClientConfig 4 | type ClientConfigGetter interface { 5 | // LocalPort 获取本地监听端口 6 | LocalPort() string 7 | // LocalAddr 获取本地监听地址 8 | LocalAddr() string 9 | // FastOpen 是否使用fast-open 10 | FastOpen() bool 11 | // PidFilePath pidfile存放路径 12 | PidFilePath() string 13 | } 14 | 15 | // ClientConfigSetter 设置ClientConfig 16 | type ClientConfigSetter interface { 17 | // SetLocalPort 设置本地监听端口 18 | SetLocalPort(port string) error 19 | // SetLocalAddr 设置本地监听地址 20 | SetLocalAddr(addr string) error 21 | // SetFastOpen 设置是否使用fast-open 22 | SetFastOpen(isFOP bool) 23 | // SetPidFilePath 设置pidfile存放路径 24 | SetPidFilePath(path string) error 25 | } 26 | 27 | // ssr client配置接口 28 | type ClientConfig interface { 29 | ClientConfigGetter 30 | ClientConfigSetter 31 | // Load 从配置文件解析config 32 | Load(path string) error 33 | // Store 保存到配置文件 34 | Store(path string) error 35 | } 36 | 37 | // ClientConfigMaker 产生新的config对象,所有选项使用初始默认值 38 | type ClientConfigMaker func() ClientConfig 39 | -------------------------------------------------------------------------------- /crawler/crawler.go: -------------------------------------------------------------------------------- 1 | package crawler 2 | 3 | import ( 4 | "compress/gzip" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/PuerkitoBio/goquery" 11 | 12 | "schannel-qt5/parser" 13 | "schannel-qt5/urls" 14 | ) 15 | 16 | // GetCSRFToken 返回本次回话所使用的CSRFToken 17 | func GetCSRFToken(proxy string) (string, []*http.Cookie, error) { 18 | client, err := GenClientWithProxy(proxy) 19 | if err != nil { 20 | return "", nil, err 21 | } 22 | 23 | request, err := http.NewRequest("GET", urls.AccountPath, nil) 24 | if err != nil { 25 | return "", nil, err 26 | } 27 | SetRequestHeader(request, nil, urls.RootPath, "gzip") 28 | 29 | resp, err := client.Do(request) 30 | if err != nil { 31 | return "", nil, err 32 | } 33 | defer resp.Body.Close() 34 | 35 | htmlReader, err := gzip.NewReader(resp.Body) 36 | if err != nil { 37 | return "", nil, err 38 | } 39 | defer htmlReader.Close() 40 | 41 | dom, err := goquery.NewDocumentFromReader(htmlReader) 42 | if err != nil { 43 | return "", nil, err 44 | } 45 | 46 | CSRFToken, exists := dom.Find("input[type='hidden'][name='token']").Eq(0).Attr("value") 47 | if !exists { 48 | return "", nil, errors.New("CSRFToken doesn't exist") 49 | } 50 | 51 | u2, _ := url.Parse(urls.RootPath) 52 | return CSRFToken, client.Jar.Cookies(u2), nil 53 | } 54 | 55 | // GetAuth 登录schannel并返回登陆成功后获得的cookies 56 | // 这些cookies在后续的页面访问中需要使用 57 | func GetAuth(user, passwd, proxy string) ([]*http.Cookie, error) { 58 | client, err := GenClientWithProxy(proxy) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | CSRFToken, session, err := GetCSRFToken(proxy) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | form := url.Values{} 69 | form.Set("token", CSRFToken) 70 | form.Set("username", user) 71 | form.Set("password", passwd) 72 | getLogin, err := http.NewRequest("POST", urls.LoginPath, strings.NewReader(form.Encode())) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | SetRequestHeader(getLogin, session, urls.AccountPath, "gzip") 78 | getLogin.Header.Set("content-type", "application/x-www-form-urlencoded") 79 | 80 | resp, err := client.Do(getLogin) 81 | if err != nil { 82 | return nil, err 83 | } 84 | defer resp.Body.Close() 85 | 86 | // 验证登录是否成功,如果incorrect的值为“true”,则登录失败 87 | incorrect := resp.Request.FormValue("incorrect") 88 | if incorrect == "true" { 89 | return nil, errors.New("登录验证失败") 90 | } 91 | 92 | u2, _ := url.Parse(urls.RootPath) 93 | cookies := make([]*http.Cookie, 0, 2) 94 | cookies = append(cookies, client.Jar.Cookies(u2)...) 95 | // 添加cfduid会话cookie 96 | for _, c := range session { 97 | if c.Name == "__cfduid" { 98 | cookies = append(cookies, c) 99 | } 100 | } 101 | 102 | return cookies, nil 103 | } 104 | 105 | // GetServiceHTML 获取所有已购买服务的状态信息,包括详细页面的地址 106 | func GetServiceHTML(cookies []*http.Cookie, proxy string) (string, error) { 107 | return getPage(urls.ServiceListPath, urls.AccountPath, cookies, proxy) 108 | } 109 | 110 | // GetInvoiceHTML 获取账单页面的HTML,包含未付款和已付款账单 111 | // 未付款账单显示在最前列 112 | // 现只支持获取第一页 113 | func GetInvoiceHTML(cookies []*http.Cookie, proxy string) (string, error) { 114 | return getPage(urls.InvoicePath, urls.AccountPath, cookies, proxy) 115 | } 116 | 117 | // GetSSRInfoHTML 获取服务详细信息页面的HTML,包含使用情况和节点信息 118 | func GetSSRInfoHTML(service *parser.Service, cookies []*http.Cookie, proxy string) (string, error) { 119 | return getPage(service.Link, urls.ServiceListPath, cookies, proxy) 120 | } 121 | 122 | // GetInvoiceInfoHTML 获取账单详情页面内容 123 | func GetInvoiceInfoHTML(invoice *parser.Invoice, cookies []*http.Cookie, proxy string) (string, error) { 124 | return getPage(invoice.Link, urls.InvoicePath, cookies, proxy) 125 | } 126 | -------------------------------------------------------------------------------- /crawler/utils.go: -------------------------------------------------------------------------------- 1 | package crawler 2 | 3 | import ( 4 | "compress/gzip" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/cookiejar" 8 | "net/url" 9 | 10 | "golang.org/x/net/publicsuffix" 11 | ) 12 | 13 | const ( 14 | // UA is Chrome's User-Agent 15 | UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.79 Safari/537.36" 16 | // AcceptType is Chrome's Accept-Type 17 | AcceptType = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" 18 | ) 19 | 20 | // GenClientWithProxy 生成http.Client,并设置代理为proxy指定的代理服务器 21 | // proxy url支持http,https和socks5协议 22 | func GenClientWithProxy(proxy string) (*http.Client, error) { 23 | client := new(http.Client) 24 | // all cookieJar users should use "golang.org/x/net/publicsuffix" 25 | jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) 26 | if err != nil { 27 | return nil, err 28 | } 29 | client.Jar = jar 30 | 31 | if proxy != "" { 32 | proxyURL, err := url.Parse(proxy) 33 | if err != nil { 34 | return nil, err 35 | } 36 | // 设置proxy 37 | client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} 38 | } 39 | 40 | return client, nil 41 | } 42 | 43 | // SetRequestHeader 设置请求头信息 44 | // cookies为nil时将被忽略,referer和compress为“”时同样被忽略 45 | func SetRequestHeader(request *http.Request, cookies []*http.Cookie, referer, compress string) { 46 | request.Header.Set("accept", AcceptType) 47 | if compress != "" { 48 | request.Header.Set("accept-encoding", compress) 49 | } 50 | if referer != "" { 51 | request.Header.Set("referer", referer) 52 | } 53 | request.Header.Set("user-agent", UA) 54 | 55 | for _, c := range cookies { 56 | request.AddCookie(c) 57 | } 58 | } 59 | 60 | // getPage 获取url指定的各种账户管理页面信息, cookies用于身份认证 61 | func getPage(url, referer string, cookies []*http.Cookie, proxy string) (string, error) { 62 | client, err := GenClientWithProxy(proxy) 63 | if err != nil { 64 | return "", err 65 | } 66 | 67 | request, err := http.NewRequest("GET", url, nil) 68 | if err != nil { 69 | return "", err 70 | } 71 | SetRequestHeader(request, cookies, referer, "gzip") 72 | 73 | resp, err := client.Do(request) 74 | if err != nil { 75 | return "", err 76 | } 77 | defer resp.Body.Close() 78 | 79 | htmlReader, err := gzip.NewReader(resp.Body) 80 | if err != nil { 81 | return "", err 82 | } 83 | defer htmlReader.Close() 84 | 85 | data, err := ioutil.ReadAll(htmlReader) 86 | if err != nil { 87 | return "", err 88 | } 89 | 90 | return string(data), nil 91 | } 92 | -------------------------------------------------------------------------------- /geoip/geoip.go: -------------------------------------------------------------------------------- 1 | package geoip 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "os" 7 | "path/filepath" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/oschwald/geoip2-golang" 12 | ) 13 | 14 | const ( 15 | // GeoIP Database的存放路径 16 | geoIPSavePath = ".local/share/data/schannel-qt5/GeoIP" 17 | // GeoIP Database下载地址 18 | DownloadPath = "https://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz" 19 | // 数据库文件名 20 | DatabaseName = "GeoLite2-City.mmdb" 21 | ) 22 | 23 | // GetGeoIPSavePath 返回完整的数据库路径,默认在$HOME/.local/share/data/schannel-qt5/GeoIP下 24 | func GetGeoIPSavePath() (string, error) { 25 | home, err := os.UserHomeDir() 26 | if err != nil { 27 | return "", errors.New("cannot find user home dir") 28 | } 29 | 30 | return filepath.Join(home, geoIPSavePath), nil 31 | } 32 | 33 | // getRecord 根据ip返回geoIP查询结果 34 | func getRecord(ip string) (*geoip2.City, error) { 35 | dbDir, err := GetGeoIPSavePath() 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | dbPath := filepath.Join(dbDir, DatabaseName) 41 | db, err := geoip2.Open(dbPath) 42 | if err != nil { 43 | return nil, err 44 | } 45 | defer db.Close() 46 | 47 | ipAddr := net.ParseIP(ip) 48 | record, err := db.City(ipAddr) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return record, nil 54 | } 55 | 56 | // GetCountryCity 返回ip的国家/地区和城市名称 57 | // lang为语言名称,e.g. "zh-CN", "en" 58 | func GetCountryCity(ip string, lang string) (string, string, error) { 59 | record, err := getRecord(ip) 60 | if err != nil { 61 | return "", "", err 62 | } 63 | 64 | var country, city string 65 | country = record.Country.Names[lang] 66 | city, exists := record.City.Names[lang] 67 | if !exists { 68 | for _, sub := range record.Subdivisions { 69 | city, exists = sub.Names[lang] 70 | if exists { 71 | break 72 | } 73 | } 74 | } 75 | 76 | return country, city, nil 77 | } 78 | 79 | // GetCountryISOCode 获取国家代码 80 | func GetCountryISOCode(ip string) (string, error) { 81 | record, err := getRecord(ip) 82 | if err != nil { 83 | return "", nil 84 | } 85 | 86 | return record.Country.IsoCode, nil 87 | } 88 | 89 | // IsGeoIPOutdated 判断数据库文件是否过期 90 | // 文件不存在时也会返回true 91 | func IsGeoIPOutdated(expires time.Duration) bool { 92 | dbPath, err := GetGeoIPSavePath() 93 | if err != nil { 94 | return true 95 | } 96 | 97 | stat, err := os.Stat(dbPath) 98 | if err != nil { 99 | return true 100 | } 101 | 102 | cTime := timespec2Time(stat.Sys().(*syscall.Stat_t).Ctim) 103 | now := time.Now() 104 | return now.Sub(cTime) >= expires 105 | } 106 | 107 | func timespec2Time(ts syscall.Timespec) time.Time { 108 | return time.Unix(int64(ts.Sec), int64(ts.Nsec)) 109 | } 110 | -------------------------------------------------------------------------------- /image/Removefixed.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /image/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/image/close.png -------------------------------------------------------------------------------- /image/download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /image/ic_copy_link.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /image/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/astaxie/beego/orm" 8 | _ "github.com/mattn/go-sqlite3" 9 | "github.com/therecipe/qt/core" 10 | std_widgets "github.com/therecipe/qt/widgets" 11 | 12 | "schannel-qt5/config" 13 | "schannel-qt5/models" 14 | _ "schannel-qt5/pyclient" 15 | "schannel-qt5/ssr" 16 | "schannel-qt5/widgets" 17 | ) 18 | 19 | const ( 20 | // 日志主前缀 21 | prefix = "schannel-qt5: " 22 | ) 23 | 24 | func init() { 25 | dbPath, err := models.GetDBPath() 26 | if err != nil { 27 | panic(err) 28 | } 29 | orm.Debug = false 30 | orm.RegisterDataBase("default", "sqlite3", dbPath) 31 | err = orm.RunSyncdb("default", false, false) 32 | if err != nil { 33 | panic(err) 34 | } 35 | } 36 | 37 | func main() { 38 | app := std_widgets.NewQApplication(len(os.Args), os.Args) 39 | app.SetAttribute(core.Qt__AA_EnableHighDpiScaling, true) 40 | 41 | // 初始化用户配置 42 | conf := &config.UserConfig{} 43 | conf.SSRClientConfig = ssr.NewClientConfig("python") 44 | err := conf.LoadConfig() 45 | if err != nil { 46 | panic(err) 47 | } 48 | 49 | var logger *log.Logger 50 | if !conf.LogFile.IsEmpty() { 51 | logPath, err := conf.LogFile.AbsPath() 52 | if err != nil { 53 | panic(err) 54 | } 55 | logFile, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0664) 56 | if err != nil { 57 | panic(err) 58 | } 59 | defer logFile.Close() 60 | logger = log.New(logFile, prefix, log.LstdFlags|log.Lshortfile) 61 | } else { 62 | // 未指定logfile时使用stdout替代 63 | logger = log.New(os.Stdout, prefix, log.LstdFlags|log.Lshortfile) 64 | } 65 | 66 | // 获取用户数据库连接 67 | db := orm.NewOrm() 68 | 69 | // 初始化GUI 70 | mainWindow := widgets.NewMainWidget2(conf, logger, db) 71 | mainWindow.Show() 72 | 73 | app.Exec() 74 | } 75 | -------------------------------------------------------------------------------- /models/encrypt.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/sha256" 8 | "encoding/base64" 9 | ) 10 | 11 | // genKey 根据用户名生成key 12 | func genKey(user string) []byte { 13 | salt := user[:len(user)/2] + "models" 14 | data := salt[:len(salt)/2] + user + salt[len(salt)/2:] 15 | hash := sha256.New() 16 | return hash.Sum([]byte(data))[:aes.BlockSize] 17 | } 18 | 19 | // encryptPassword 加密用户名密码,返回加密后的数据 20 | func encryptPassword(user string, password string) (string, error) { 21 | key := genKey(user) 22 | block, err := aes.NewCipher(key) 23 | if err != nil { 24 | return "", err 25 | } 26 | 27 | origData := PKCS5Padding([]byte(password), block.BlockSize()) 28 | blockMode := cipher.NewCBCEncrypter(block, key) 29 | crypted := make([]byte, len(origData)) 30 | blockMode.CryptBlocks(crypted, origData) 31 | res := base64.StdEncoding.EncodeToString(crypted) 32 | return res, nil 33 | } 34 | 35 | // decryptPassword 返回解密后的信息 36 | func decryptPassword(user string, crypted string) (string, error) { 37 | key := genKey(user) 38 | block, err := aes.NewCipher(key) 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | blockMode := cipher.NewCBCDecrypter(block, key) 44 | unbase, err := base64.StdEncoding.DecodeString(crypted) 45 | if err != nil { 46 | return "", err 47 | } 48 | 49 | n := len(unbase) 50 | origData := make([]byte, n) 51 | blockMode.CryptBlocks(origData, unbase) 52 | origData = PKCS5UnPadding(origData) 53 | return string(origData), nil 54 | } 55 | 56 | // PKCS5Padding 将数据填充至合适的大小,以便加密算法处理 57 | func PKCS5Padding(origData []byte, blockSize int) []byte { 58 | padding := blockSize - len(origData)%blockSize 59 | padText := bytes.Repeat([]byte{byte(padding)}, padding) 60 | return append(origData, padText...) 61 | } 62 | 63 | // PKCS5UnPadding 去除填充 64 | func PKCS5UnPadding(origData []byte) []byte { 65 | length := len(origData) 66 | // 去掉最后一个字节 unpadding 次 67 | unpadding := int(origData[length-1]) 68 | return origData[:(length - unpadding)] 69 | } 70 | -------------------------------------------------------------------------------- /models/encrypt_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "testing" 4 | 5 | func TestEncryptDecrypt(t *testing.T) { 6 | testData := []*struct { 7 | user string 8 | password string 9 | }{ 10 | { 11 | user: "test", 12 | password: "abcdefg123", 13 | }, 14 | { 15 | user: "example user", 16 | password: "foratest1990812", 17 | }, 18 | { 19 | user: "用户A1@", 20 | password: "worldHello17.,", 21 | }, 22 | { 23 | user: "sample1", 24 | password: genPassword(), 25 | }, 26 | } 27 | 28 | for _, v := range testData { 29 | crypted, err := encryptPassword(v.user, v.password) 30 | if err != nil { 31 | t.Errorf("encrypto: %v\n", err) 32 | } 33 | password, err := decryptPassword(v.user, crypted) 34 | if err != nil { 35 | t.Errorf("decrypto: %v\n", err) 36 | } 37 | if password != v.password { 38 | format := "password not equal, old: %s, new: %s\n" 39 | t.Errorf(format, v.password, password) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /models/used_amount.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/astaxie/beego/orm" 7 | 8 | "schannel-qt5/parser" 9 | ) 10 | 11 | // 用户的每日的流量使用记录,以service进行区分 12 | type UsedAmount struct { 13 | Id int `orm:"auto"` 14 | Service string `orm:"size(50)"` 15 | Total int 16 | Upload int 17 | Download int 18 | Date time.Time `orm:"type(date)"` 19 | User *User `orm:"rel(fk);on_delete(cascade)"` 20 | } 21 | 22 | // 设置数据库时区为UTC 23 | func init() { 24 | orm.RegisterModel(&UsedAmount{}) 25 | orm.DefaultTimeLoc = time.UTC 26 | } 27 | 28 | // SetUsedAmount 根据sevice插入使用量信息. 29 | // 若date已经存在,则更新数据 30 | func SetUsedAmount(db orm.Ormer, 31 | user string, 32 | service *parser.Service, 33 | total, upload, download int, 34 | date time.Time) error { 35 | amount := &UsedAmount{ 36 | Service: service.Name, 37 | Total: total, 38 | Upload: upload, 39 | Download: download, 40 | Date: date, 41 | User: &User{ 42 | Name: user, 43 | }, 44 | } 45 | // 用户不存在无法insert 46 | err := db.QueryTable(amount.User).Filter("Name", user).One(amount.User) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | cond := orm.NewCondition() 52 | cond = cond.And("Date", amount.Date). 53 | And("Service", amount.Service). 54 | And("User__Name", amount.User.Name) 55 | if db.QueryTable(amount).SetCond(cond).Exist() { 56 | db.QueryTable(amount).SetCond(cond).Update(orm.Params{ 57 | "Upload": amount.Upload, 58 | "Download": amount.Download, 59 | }) 60 | return nil 61 | } 62 | 63 | if _, err := db.Insert(amount); err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | const ( 71 | // 需要获取的天数 72 | maxDays = 5 73 | ) 74 | 75 | // GetRecentUsedAmount 返回date开始最近maxDays天的使用量数据 76 | // 如果数量不足5天,则以最早一天的数据进行复制补足 77 | func GetRecentUsedAmount(db orm.Ormer, 78 | user, service string, 79 | date time.Time) ([]*UsedAmount, error) { 80 | amounts := make([]*UsedAmount, 0) 81 | cond := orm.NewCondition() 82 | cond = cond.And("User__Name", user). 83 | And("Service", service). 84 | And("Date__lte", date) 85 | n, err := db.QueryTable(&UsedAmount{}). 86 | SetCond(cond). 87 | OrderBy("-Date"). 88 | Limit(maxDays).All(&amounts) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | // 将时间转回本地时区 94 | for _, v := range amounts { 95 | v.Date = v.Date.In(time.Local) 96 | } 97 | 98 | if n < maxDays { 99 | amounts = paddingDate(amounts, int(n), maxDays) 100 | } 101 | 102 | return amounts, nil 103 | } 104 | 105 | // paddingDate 填充UsedAmount至长度为max 106 | func paddingDate(amounts []*UsedAmount, length, max int) []*UsedAmount { 107 | origin := amounts[length-1] 108 | // 需要减去的天数 109 | dayCount := 1 110 | for i := length; i < max; i++ { 111 | duplicate := &UsedAmount{ 112 | Service: origin.Service, 113 | Total: origin.Total, 114 | Upload: origin.Upload, 115 | Download: origin.Download, 116 | Date: origin.Date.Add(time.Duration(-dayCount) * time.Hour * 24), 117 | User: origin.User, 118 | } 119 | dayCount++ 120 | amounts = append(amounts, duplicate) 121 | } 122 | 123 | return amounts 124 | } 125 | -------------------------------------------------------------------------------- /models/users.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/astaxie/beego/orm" 8 | _ "github.com/mattn/go-sqlite3" 9 | 10 | "schannel-qt5/config" 11 | ) 12 | 13 | const ( 14 | // 数据库存放路径 15 | databasePath = ".local/share/schannel-users.db" 16 | ) 17 | 18 | // 注册模型 19 | func init() { 20 | orm.RegisterModel(&User{}) 21 | } 22 | 23 | // GetDBPath 获取数据库存放路径 24 | func GetDBPath() (string, error) { 25 | home, err := os.UserHomeDir() 26 | if err != nil { 27 | return "", config.ErrHOME 28 | } 29 | 30 | return filepath.Join(home, databasePath), nil 31 | } 32 | 33 | // User 用户表,将和使用量表关联 34 | type User struct { 35 | Name string `orm:"size(100);pk"` 36 | Passwd string `orm:"size(100);null"` 37 | } 38 | 39 | // GetUserPassword 获取用户名以及密码 40 | func GetUserPassword(db orm.Ormer, user string) (*User, error) { 41 | u := &User{Name: user} 42 | 43 | if err := db.Read(u); err != nil { 44 | return nil, err 45 | } 46 | 47 | // 密码解密 48 | if u.Passwd != "" { 49 | data, err := decryptPassword(u.Name, u.Passwd) 50 | if err != nil { 51 | return nil, err 52 | } 53 | u.Passwd = data 54 | } 55 | 56 | return u, nil 57 | } 58 | 59 | // SetUserPassword 将用户名密码保存 60 | // password为nil表示不记住密码 61 | func SetUserPassword(db orm.Ormer, user string, password string) error { 62 | u := &User{ 63 | Name: user, 64 | Passwd: password, 65 | } 66 | 67 | if u.Passwd != "" { 68 | data, err := encryptPassword(u.Name, u.Passwd) 69 | if err != nil { 70 | return err 71 | } 72 | u.Passwd = data 73 | } 74 | 75 | if db.QueryTable(u).Filter("Name", u.Name).Exist() { 76 | old := &User{Name: u.Name} 77 | db.QueryTable(old).Filter("Name", old.Name).One(old) 78 | // 和旧值一样,不更新,返回nil 79 | if u.Passwd == old.Passwd { 80 | return nil 81 | } 82 | _, err := db.QueryTable(u).Filter("Name", u.Name).Update(orm.Params{ 83 | "Passwd": u.Passwd, 84 | }) 85 | if err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | 91 | if _, err := db.Insert(u); err != nil { 92 | return err 93 | } 94 | 95 | return nil 96 | } 97 | 98 | // GetAllUsers 返回所有user,包括未 99 | func GetAllUsers(db orm.Ormer) ([]*User, error) { 100 | users := make([]*User, 0) 101 | 102 | if _, err := db.QueryTable(&User{}).All(&users); err != nil { 103 | return nil, err 104 | } 105 | 106 | return users, nil 107 | } 108 | 109 | // DelPassword 将指定user的password设置为null 110 | func DelPassword(db orm.Ormer, user string) error { 111 | u := &User{Name: user} 112 | 113 | _, err := db.QueryTable(u).Filter("Name", u.Name).Update(orm.Params{ 114 | "Passwd": "", 115 | }) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | return nil 121 | } 122 | 123 | // DelUser 删除名字与name相同的User,同时会删除UserAmount记录 124 | func DelUser(db orm.Ormer, name string) error { 125 | user := &User{Name: name} 126 | _, err := db.QueryTable(user).Filter("Name", user.Name).Delete() 127 | return err 128 | } 129 | -------------------------------------------------------------------------------- /models/users_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | 6 | "crypto/rand" 7 | "encoding/hex" 8 | "os" 9 | 10 | "github.com/astaxie/beego/orm" 11 | _ "github.com/mattn/go-sqlite3" 12 | ) 13 | 14 | const ( 15 | // 测试数据库存储路径 16 | dbPath = "/tmp/db_users_test.db" 17 | ) 18 | 19 | func TestMain(m *testing.M) { 20 | orm.Debug = true 21 | orm.RegisterDataBase("default", "sqlite3", dbPath) 22 | orm.RegisterDataBase("testAmount", "sqlite3", amountPath) 23 | os.Exit(m.Run()) 24 | } 25 | 26 | // initUserDB 初始化测试数据 27 | func initUserDB(t *testing.T) (orm.Ormer, []*User) { 28 | os.Truncate(dbPath, 0) 29 | err := orm.RunSyncdb("default", false, true) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | users := []*User{ 35 | { 36 | Name: "test@test.com", 37 | Passwd: "", 38 | }, 39 | { 40 | Name: "test@example.com", 41 | Passwd: genPassword(), 42 | }, 43 | { 44 | Name: "example", 45 | Passwd: genPassword(), 46 | }, 47 | } 48 | 49 | db := orm.NewOrm() 50 | for _, v := range users { 51 | if err := SetUserPassword(db, v.Name, v.Passwd); err != nil { 52 | t.Fatalf("initdb error: %v\n", err) 53 | } 54 | } 55 | 56 | return db, users 57 | } 58 | 59 | // genPassword 生成随机密码 60 | func genPassword() string { 61 | origData := make([]byte, 16) 62 | n, err := rand.Read(origData) 63 | if err != nil { 64 | panic(err) 65 | } 66 | 67 | pw := make([]byte, hex.EncodedLen(n)) 68 | hex.Encode(pw, origData[:n]) 69 | return string(pw) 70 | } 71 | 72 | func TestGetUserPassword(t *testing.T) { 73 | db, users := initUserDB(t) 74 | 75 | for _, v := range users { 76 | user, err := GetUserPassword(db, v.Name) 77 | if err != nil { 78 | t.Errorf("get user: %s error: %v\n", v.Name, err) 79 | } 80 | if user.Passwd != v.Passwd { 81 | format := "get user: %s password different\nhave: %v\n\twant: %v\n" 82 | t.Errorf(format, v.Name, user.Passwd, v.Passwd) 83 | } 84 | } 85 | } 86 | 87 | func TestSetUserPassword(t *testing.T) { 88 | db, _ := initUserDB(t) 89 | 90 | testData := []*struct { 91 | // 用户对象 92 | u *User 93 | // 是否insert成功 94 | inserted bool 95 | }{ 96 | { 97 | u: &User{ 98 | Name: "example", 99 | Passwd: "", 100 | }, 101 | inserted: true, 102 | }, 103 | { 104 | u: &User{ 105 | Name: "a", 106 | Passwd: genPassword(), 107 | }, 108 | inserted: true, 109 | }, 110 | { 111 | u: &User{ 112 | Name: "b", 113 | Passwd: "", 114 | }, 115 | inserted: true, 116 | }, 117 | } 118 | 119 | for _, v := range testData { 120 | err := SetUserPassword(db, v.u.Name, v.u.Passwd) 121 | if (err == nil) != v.inserted { 122 | t.Errorf("set user: %v error: %v\n", v.u, err) 123 | } 124 | } 125 | } 126 | 127 | func TestGetAllUsers(t *testing.T) { 128 | db, users := initUserDB(t) 129 | 130 | u, err := GetAllUsers(db) 131 | if err != nil { 132 | t.Errorf("get all users error: %v\n", err) 133 | } 134 | 135 | // 取得的数据量是否相同 136 | if len(u) != len(users) { 137 | format := "length error: have: %v\n\twant: %v\n" 138 | t.Errorf(format, len(u), len(users)) 139 | } 140 | } 141 | 142 | func TestDelPassword(t *testing.T) { 143 | db, users := initUserDB(t) 144 | 145 | for _, v := range users { 146 | if v.Passwd != "" { 147 | err := DelPassword(db, v.Name) 148 | if err != nil { 149 | format := "del %s password error: %v\n" 150 | t.Errorf(format, v.Name, err) 151 | } 152 | 153 | // 查看是否已将密码设置为null 154 | u, err := GetUserPassword(db, v.Name) 155 | if err != nil { 156 | format := "del %s password error: %v\n" 157 | t.Errorf(format, v.Name, err) 158 | } 159 | if u.Passwd != "" { 160 | t.Errorf("del password failed: %v\n", u.Passwd) 161 | } 162 | } 163 | } 164 | } 165 | 166 | func TestGetDBPath(t *testing.T) { 167 | testData := []*struct { 168 | // 设置环境变量HOME的值 169 | home string 170 | res string 171 | }{ 172 | { 173 | home: "/home/test", 174 | res: "/home/test/" + databasePath, 175 | }, 176 | { 177 | home: "/home/user1/", 178 | res: "/home/user1/" + databasePath, 179 | }, 180 | { 181 | home: "/home/用户/", 182 | res: "/home/用户/" + databasePath, 183 | }, 184 | } 185 | 186 | for _, v := range testData { 187 | os.Clearenv() 188 | err := os.Setenv("HOME", v.home) 189 | if err != nil { 190 | t.Fatalf("无法设置$HOME: %v\n", err) 191 | } 192 | res, err := GetDBPath() 193 | if err != nil { 194 | t.Fatalf("获取DB Path错误:%v\n", err) 195 | } 196 | if v.res != res { 197 | format := "不正确的DB Path:\n\twant: %s\n\thave: %v\n" 198 | t.Errorf(format, v.res, res) 199 | } 200 | } 201 | } 202 | 203 | func TestDelUser(t *testing.T) { 204 | db, users := initUserDB(t) 205 | for _, v := range users { 206 | err := DelUser(db, v.Name) 207 | if err != nil { 208 | format := "DelUser error: %+v\n" 209 | t.Errorf(format, v) 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /parser/invoice.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // PaymentState 表示账单的付款状态 8 | type PaymentState int 9 | 10 | const ( 11 | // NeedPay 未付款 12 | NeedPay PaymentState = iota 13 | // FinishedPay 已付款 14 | FinishedPay 15 | ) 16 | 17 | // Invoice 账单信息 18 | type Invoice struct { 19 | // 账单编号 20 | Number string 21 | // 账单链接 22 | Link string 23 | // 账单开始日期 24 | StartDate time.Time 25 | // 账单结束日期 26 | ExpireDate time.Time 27 | // 支付金额 28 | Payment int64 29 | // 付款状态 30 | State PaymentState 31 | } 32 | 33 | // GetStatus 返回账单的状态 34 | // 未付款会返回false 35 | // time.Now()超过ExpireDate将视为账单过期 36 | func (i *Invoice) GetStatus() (string, bool) { 37 | msg := "" 38 | flag := false 39 | 40 | if i.State == NeedPay { 41 | msg += "需要付款" 42 | } else if i.State == FinishedPay { 43 | msg += "无需付款" 44 | flag = true 45 | } 46 | 47 | current := GetCurrentDay() 48 | if current.After(i.ExpireDate) { 49 | msg += ",账单过期" 50 | } 51 | 52 | return msg, flag 53 | } 54 | 55 | // GetCurrentDay 返回当前的时间,精确到day 56 | func GetCurrentDay() time.Time { 57 | timeFormat := "2006-01-02" 58 | dayText := time.Now().Format(timeFormat) 59 | day, _ := time.ParseInLocation(timeFormat, dayText, time.Local) 60 | return day 61 | } 62 | -------------------------------------------------------------------------------- /parser/invoice_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "testing" 5 | 6 | "time" 7 | ) 8 | 9 | func TestInvoiceGetStatus(t *testing.T) { 10 | // 与过期时间比较 11 | now := GetCurrentDay() 12 | 13 | testData := []*struct { 14 | // 账单信息 15 | i *Invoice 16 | // err string 17 | info string 18 | // 是否付款 19 | isPayed bool 20 | }{ 21 | { 22 | i: &Invoice{ 23 | // 设置未过期时间 24 | ExpireDate: now.Add(24 * time.Hour), 25 | State: FinishedPay, 26 | }, 27 | info: "无需付款", 28 | isPayed: true, 29 | }, 30 | { 31 | i: &Invoice{ 32 | // 测试当前时间 33 | ExpireDate: now, 34 | State: FinishedPay, 35 | }, 36 | info: "无需付款", 37 | isPayed: true, 38 | }, 39 | { 40 | i: &Invoice{ 41 | // 设置已过期时间 42 | ExpireDate: now.Add(-24 * time.Hour), 43 | State: FinishedPay, 44 | }, 45 | info: "无需付款,账单过期", 46 | isPayed: true, 47 | }, 48 | { 49 | i: &Invoice{ 50 | ExpireDate: now.Add(24 * time.Hour), 51 | State: NeedPay, 52 | }, 53 | info: "需要付款", 54 | isPayed: false, 55 | }, 56 | { 57 | i: &Invoice{ 58 | ExpireDate: now.Add(-24 * time.Hour), 59 | State: NeedPay, 60 | }, 61 | info: "需要付款,账单过期", 62 | isPayed: false, 63 | }, 64 | } 65 | 66 | for _, v := range testData { 67 | info, state := v.i.GetStatus() 68 | if info != v.info { 69 | format := "wrong info:\n\twant :%s\n\thave: %s\n%v\n" 70 | t.Errorf(format, v.info, info, v) 71 | } 72 | if state != v.isPayed { 73 | format := "wrong payment state:\n\twant: %v\n\thave: %v\n%v\n" 74 | t.Errorf(format, v.isPayed, state, v) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/PuerkitoBio/goquery" 10 | 11 | "schannel-qt5/urls" 12 | ) 13 | 14 | var ( 15 | getDataInfo = regexp.MustCompile(`.+ \((.+(?:GB|MB|KB))\)`) 16 | getTotal = regexp.MustCompile(`.+ \(流量:(.+(?:GB|MB|KB))\)`) 17 | ) 18 | 19 | func isServiceValid(stat string) bool { 20 | return stat == "有效的" 21 | } 22 | 23 | // GetService 返回所有可用的套餐的信息 24 | func GetService(data string) []*Service { 25 | res := make([]*Service, 0) 26 | 27 | table, _ := goquery.NewDocumentFromReader(strings.NewReader(data)) 28 | // id为tableServicesList的table里有所有的服务信息 29 | table.Find("#tableServicesList tbody tr").Each(func(i int, s *goquery.Selection) { 30 | tds := s.Find("td") 31 | if !isServiceValid(tds.Eq(3).Text()) { 32 | // 滤除已暂停的服务 33 | return 34 | } 35 | 36 | ser := new(Service) 37 | // 第一列是服务名称 38 | ser.Name = tds.Eq(0).Text() 39 | // 第二列是详细信息页面链接和价格 40 | link, _ := tds.Eq(1).Find("a").Attr("href") 41 | ser.Link = urls.RootPath + link 42 | ser.Price = tds.Eq(1).Text() 43 | // 第三列是服务到期时间 44 | expire := tds.Eq(2).Find("span").Text() 45 | ser.Expires, _ = time.ParseInLocation("2006-01-02", expire, time.Local) 46 | // 第四列是服务状态信息 47 | ser.State = tds.Eq(3).Text() 48 | res = append(res, ser) 49 | }) 50 | 51 | return res 52 | } 53 | 54 | // GetSSRInfo 获取套餐的详细使用信息 55 | func GetSSRInfo(data string, ser *Service) *SSRInfo { 56 | res := NewSSRInfo(ser) 57 | 58 | dom, _ := goquery.NewDocumentFromReader(strings.NewReader(data)) 59 | // 信息包含在第2-4个section.panel里 60 | sections := dom.Find("section.panel") 61 | 62 | // 第2个section的table是端口和密码 63 | serverInfo := sections.Eq(1) 64 | portAndPasswd := serverInfo.Find("table").First() 65 | res.Port, _ = strconv.ParseInt(portAndPasswd.Find("tbody tr").Find("td").First().Text(), 10, 64) 66 | res.Passwd = portAndPasswd.Find("tbody tr").Find("td").Eq(1).Text() 67 | 68 | // 第3个section的header里是套餐总量 69 | usageInfo := sections.Eq(2) 70 | total := usageInfo.Find("header").Text() 71 | total = strings.TrimSpace(total) 72 | res.TotalData = getTotal.FindStringSubmatch(total)[1] 73 | 74 | usage := usageInfo.Find("#plugin-usage").Find("p") 75 | res.UsedData = getDataInfo.FindStringSubmatch(usage.First().Text())[1] 76 | res.Upload = getDataInfo.FindStringSubmatch(usage.Eq(1).Text())[1] 77 | res.Download = getDataInfo.FindStringSubmatch(usage.Eq(2).Text())[1] 78 | 79 | // 第4个section的table是节点信息表 80 | sections.Eq(3).Find("table"). 81 | Find("tbody tr").Each(func(i int, s *goquery.Selection) { 82 | node := new(SSRNode) 83 | tds := s.Children() 84 | 85 | node.NodeName = tds.First().Text() 86 | node.Type = tds.Eq(1).Text() 87 | node.IP = tds.Eq(2).Text() 88 | node.Crypto = tds.Eq(3).Text() 89 | node.Proto = tds.Eq(4).Text() 90 | node.Minx = tds.Eq(5).Text() 91 | node.Port = res.Port 92 | node.Passwd = res.Passwd 93 | 94 | res.Nodes = append(res.Nodes, node) 95 | }) 96 | 97 | return res 98 | } 99 | 100 | // GetInvoices 返回所有账单信息 101 | func GetInvoices(data string) []*Invoice { 102 | invoiceList := make([]*Invoice, 0, 2) 103 | 104 | dom, _ := goquery.NewDocumentFromReader(strings.NewReader(data)) 105 | invoiceTable := dom.Find("#tableInvoicesList") 106 | 107 | invoiceTable.Find("tbody tr").Each(func(_ int, s *goquery.Selection) { 108 | invoice := new(Invoice) 109 | tds := s.Find("td") 110 | 111 | invoice.Number = tds.First().Text() 112 | 113 | startDate := tds.Eq(1).Find("span").Text() 114 | expireDate := tds.Eq(2).Find("span").Text() 115 | invoice.StartDate, _ = time.ParseInLocation("2006-01-02", startDate, time.Local) 116 | invoice.ExpireDate, _ = time.ParseInLocation("2006-01-02", expireDate, time.Local) 117 | 118 | payment, exists := tds.Eq(3).Attr("data-order") 119 | // 如果取不到,就用默认值0 120 | if exists { 121 | invoice.Payment, _ = strconv.ParseInt(payment, 10, 64) 122 | } 123 | 124 | if tds.Eq(4).Text() == "已付款" { 125 | invoice.State = FinishedPay 126 | } else if tds.Eq(4).Text() == "未付款" { 127 | invoice.State = NeedPay 128 | } 129 | 130 | link, _ := tds.Eq(5).Find("a").Attr("href") 131 | invoice.Link = urls.RootPath + link 132 | 133 | invoiceList = append(invoiceList, invoice) 134 | }) 135 | 136 | return invoiceList 137 | } 138 | 139 | // GetInvoiceDownloadURL 获取invoice下载地址 140 | func GetInvoiceDownloadURL(data string) string { 141 | dom, _ := goquery.NewDocumentFromReader(strings.NewReader(data)) 142 | downloadBtn := dom.Find("i.fa-download").Parent() 143 | downloadURL, exists := downloadBtn.Attr("href") 144 | if !exists { 145 | return "" 146 | } 147 | 148 | return urls.RootPath + downloadURL 149 | } 150 | 151 | // GetInvoiceViewHTML 处理invoice html生成用于QWebEngine展示的页面 152 | func GetInvoiceViewHTML(data string) string { 153 | dom, _ := goquery.NewDocumentFromReader(strings.NewReader(data)) 154 | 155 | // 修改css链接为绝对路径 156 | dom.Find("head link").Each(func(_ int, link *goquery.Selection) { 157 | if val, exists := link.Attr("href"); exists { 158 | link.SetAttr("href", urls.RootPath+val[1:]) 159 | } 160 | }) 161 | 162 | dom.Find(".hidden-print").Each(func(_ int, s *goquery.Selection) { 163 | s.Remove() 164 | }) 165 | 166 | ret, _ := dom.Html() 167 | return ret 168 | } 169 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "testing" 5 | 6 | "io/ioutil" 7 | "os" 8 | "time" 9 | 10 | "schannel-qt5/urls" 11 | ) 12 | 13 | func TestGetTotal(t *testing.T) { 14 | testData := []*struct { 15 | data string 16 | // parse得到的值 17 | res string 18 | }{ 19 | { 20 | data: `使用报表 (流量:50GB)`, 21 | res: "50GB", 22 | }, 23 | { 24 | data: `使用报表 (流量:25.25GB)`, 25 | res: "25.25GB", 26 | }, 27 | } 28 | 29 | for _, v := range testData { 30 | res := getTotal.FindStringSubmatch(v.data)[1] 31 | if res != v.res { 32 | format := "regexp getTotal failed: %v\n\twant: %v\n\thave: %v\n" 33 | t.Errorf(format, v, v.res, res) 34 | } 35 | } 36 | } 37 | 38 | func TestGetDataInfo(t *testing.T) { 39 | testData := []*struct { 40 | data string 41 | // parse得到的值 42 | res string 43 | }{ 44 | { 45 | data: `已使用 (16.14GB)`, 46 | res: "16.14GB", 47 | }, 48 | { 49 | data: `上传 (14.66MB)`, 50 | res: "14.66MB", 51 | }, 52 | { 53 | data: `下载 (16.12GB)`, 54 | res: "16.12GB", 55 | }, 56 | { 57 | data: `下载 (5.74KB)`, 58 | res: "5.74KB", 59 | }, 60 | } 61 | 62 | for _, v := range testData { 63 | res := getDataInfo.FindStringSubmatch(v.data)[1] 64 | if res != v.res { 65 | format := "regexp getDataInfo failed: %s\n\twant: %s\n\thave: %s\n" 66 | t.Errorf(format, v.data, v.res, res) 67 | } 68 | } 69 | } 70 | 71 | func TestGetInvoice(t *testing.T) { 72 | // 应该被解析出来的信息 73 | correctRes := []Invoice{ 74 | { 75 | Number: "12345", 76 | Link: urls.RootPath + "test1", 77 | Payment: 10, 78 | State: NeedPay, 79 | }, { 80 | Number: "2345", 81 | Link: urls.RootPath + "test2", 82 | Payment: 10, 83 | State: FinishedPay, 84 | }, { 85 | Number: "345", 86 | Link: urls.RootPath + "test3", 87 | Payment: 10, 88 | State: FinishedPay, 89 | }, { 90 | Number: "4390", 91 | Link: urls.RootPath + "test4", 92 | Payment: 10, 93 | State: FinishedPay, 94 | }, 95 | } 96 | 97 | correctRes[0].StartDate, _ = time.ParseInLocation("2006-01-02", "2018-04-10", time.Local) 98 | correctRes[0].ExpireDate, _ = time.ParseInLocation("2006-01-02", "2018-04-11", time.Local) 99 | 100 | correctRes[1].StartDate, _ = time.ParseInLocation("2006-01-02", "2018-04-30", time.Local) 101 | correctRes[1].ExpireDate, _ = time.ParseInLocation("2006-01-02", "2018-04-30", time.Local) 102 | 103 | correctRes[2].StartDate, _ = time.ParseInLocation("2006-01-02", "2018-05-28", time.Local) 104 | correctRes[2].ExpireDate, _ = time.ParseInLocation("2006-01-02", "2018-05-29", time.Local) 105 | 106 | correctRes[3].StartDate, _ = time.ParseInLocation("2006-01-02", "2018-06-28", time.Local) 107 | correctRes[3].ExpireDate, _ = time.ParseInLocation("2006-01-02", "2018-06-29", time.Local) 108 | 109 | f, err := os.Open("testdata/invoice.html") 110 | if err != nil { 111 | t.Error(err) 112 | } 113 | defer f.Close() 114 | testData, err := ioutil.ReadAll(f) 115 | if err != nil { 116 | t.Error(err) 117 | } 118 | 119 | res := GetInvoices(string(testData)) 120 | if len(res) != len(correctRes) { 121 | format := "解析到的数据量不正确,期望%d个,实际%d个\n" 122 | t.Errorf(format, len(correctRes), len(res)) 123 | } 124 | 125 | for i := range res { 126 | if res[i].Number != correctRes[i].Number { 127 | format := "订单号错误,期望%v,实际%v\n" 128 | t.Errorf(format, correctRes[i].Number, res[i].Number) 129 | } 130 | if res[i].Link != correctRes[i].Link { 131 | format := "链接错误,期望%v,实际%v\n" 132 | t.Errorf(format, correctRes[i].Link, res[i].Link) 133 | } 134 | if res[i].ExpireDate != correctRes[i].ExpireDate { 135 | format := "过期日期错误,期望%v,实际%v\n" 136 | t.Errorf(format, correctRes[i].ExpireDate, res[i].ExpireDate) 137 | } 138 | if res[i].StartDate != correctRes[i].StartDate { 139 | format := "开始日期错误, 期望%v,实际%v\n" 140 | t.Errorf(format, correctRes[i].StartDate, res[i].StartDate) 141 | } 142 | if res[i].Payment != correctRes[i].Payment { 143 | format := "支付金额错误,期望%v,实际%v\n" 144 | t.Errorf(format, correctRes[i].Payment, res[i].Payment) 145 | } 146 | if res[i].State != correctRes[i].State { 147 | format := "状态错误,期望%v,实际%v\n" 148 | t.Errorf(format, correctRes[i].State, res[i].State) 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /parser/service.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Service 购买的服务信息 8 | type Service struct { 9 | // 服务名称 10 | Name string 11 | // 服务详细信息链接 12 | Link string 13 | // 服务价格 14 | Price string 15 | // 服务过期时间 16 | Expires time.Time 17 | // 服务状态:是否可用/是否需要付费 18 | State string 19 | } 20 | -------------------------------------------------------------------------------- /parser/ssrinfo.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | // SSRInfo ssr套餐信息 4 | type SSRInfo struct { 5 | *Service 6 | 7 | // 节点的端口和密码 8 | Port int64 9 | Passwd string 10 | 11 | // 可用数据总量 12 | TotalData string 13 | // 已用数据总量 14 | UsedData string 15 | // 下载用量 16 | Download string 17 | // 上传用量 18 | Upload string 19 | 20 | // 可用节点信息 21 | Nodes []*SSRNode 22 | } 23 | 24 | // NewSSRInfo 生成SSRInfo 25 | func NewSSRInfo(ser *Service) *SSRInfo { 26 | s := new(SSRInfo) 27 | s.Service = ser 28 | s.Nodes = make([]*SSRNode, 0) 29 | return s 30 | } 31 | -------------------------------------------------------------------------------- /parser/ssrnode.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | // SSRNode ssr节点信息 11 | type SSRNode struct { 12 | // 节点名字 13 | NodeName string `json:"node_name"` 14 | // 节点类型 15 | Type string `json:"-"` 16 | 17 | // 节点IP地址 18 | IP string `json:"server"` 19 | Port int64 `json:"server_port"` 20 | Passwd string `json:"password"` 21 | 22 | // 加密算法 23 | Crypto string `json:"method"` 24 | // 连接协议 25 | Proto string `json:"protocol"` 26 | // 混淆算法 27 | Minx string `json:"obfs"` 28 | } 29 | 30 | // Store 将配置信息存入json文件 31 | func (s *SSRNode) Store(path string) error { 32 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0664) 33 | if err != nil { 34 | return err 35 | } 36 | defer f.Close() 37 | 38 | data, err := json.MarshalIndent(s, "", "\t") 39 | if err != nil { 40 | return err 41 | } 42 | 43 | if _, err := f.Write(data); err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | // Load 从配置文件读取node信息 51 | func (s *SSRNode) Load(path string) error { 52 | f, err := os.Open(path) 53 | if err != nil { 54 | return err 55 | } 56 | defer f.Close() 57 | 58 | data, err := ioutil.ReadAll(f) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | err = json.Unmarshal(data, s) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | return nil 69 | } 70 | 71 | // NameNumber 获取节点的编号 72 | func (s *SSRNode) NameNumber() string { 73 | nameSplit := strings.Split(s.NodeName, "_") 74 | return "节点" + nameSplit[len(nameSplit)-1] 75 | } 76 | -------------------------------------------------------------------------------- /parser/ssrnode_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "testing" 5 | 6 | "encoding/json" 7 | ) 8 | 9 | func TestMarshalNode(t *testing.T) { 10 | node := new(SSRNode) 11 | node.NodeName = "unreachable" 12 | node.Type = "ssr" 13 | node.IP = "0.0.0.0" 14 | node.Port = 1 15 | node.Passwd = "123" 16 | node.Crypto = "aes" 17 | node.Proto = "http" 18 | node.Minx = "non" 19 | 20 | data, err := json.Marshal(node) 21 | if err != nil { 22 | t.Error(err) 23 | } 24 | t.Log(string(data)) 25 | } 26 | 27 | func TestUnmarshalNode(t *testing.T) { 28 | node := new(SSRNode) 29 | node.NodeName = "test" 30 | node.Type = "ssr" 31 | 32 | data := `{"node_name":"a","type":"ss","server":"0.0.0.0","server_port":1,"password":"123","method":"aes","protocol":"http","obfs":"non"}` 33 | if err := json.Unmarshal([]byte(data), node); err != nil { 34 | t.Error(err) 35 | } 36 | if node.NodeName != "test" || node.Type != "ssr" { 37 | t.Error("unmarshal error") 38 | } 39 | t.Log(*node) 40 | } 41 | -------------------------------------------------------------------------------- /parser/testdata/invoice.html: -------------------------------------------------------------------------------- 1 | 2 | test invoice 3 | 4 | 5 |
6 |
7 | 10 | 11 |
12 |
13 |
    14 |
  • 15 | 16 |
  • 17 |
  • 18 | 19 |
  • 20 |
21 |
22 | 我的账单 23 |
24 |
25 |
26 | 27 |
28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 90 | 91 | 92 | 93 |
94 |

加载中...

95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /pyclient/pyssrclient.go: -------------------------------------------------------------------------------- 1 | package pyclient 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "os/exec" 10 | "syscall" 11 | "time" 12 | 13 | "schannel-qt5/config" 14 | "schannel-qt5/ssr" 15 | "schannel-qt5/urls" 16 | ) 17 | 18 | // PySSRClient 调用Python实现的ssr客户端 19 | type PySSRClient struct { 20 | // 可执行程序的路径 21 | bin string 22 | // binArg 运行参数 23 | binArgs []string 24 | // 程序需要的配置 25 | conf config.ClientConfig 26 | } 27 | 28 | func init() { 29 | // 注册为可用的Launcher,name为python 30 | ssr.SetLuancherMaker("python", ssr.LauncherMaker(newPySSRClient)) 31 | } 32 | 33 | // newPySSRClient 这个函数供ssr.LauncherMaker调用,用于生成ssr.Launcher 34 | func newPySSRClient(c *config.UserConfig) ssr.Launcher { 35 | p := &PySSRClient{} 36 | bin, err := c.SSRBin.AbsPath() 37 | if err != nil { 38 | log.Println(err) 39 | return nil 40 | } 41 | p.bin = bin 42 | 43 | nodeConfigFile, err := c.SSRNodeConfigPath.AbsPath() 44 | if err != nil { 45 | log.Println(err) 46 | return nil 47 | } 48 | 49 | p.conf = c.SSRClientConfig 50 | 51 | // -c ssr_node_config_file 52 | p.binArgs = []string{"python", p.bin} 53 | p.binArgs = append(p.binArgs, "-c", nodeConfigFile) 54 | p.binArgs = append(p.binArgs, p.conf.(*ClientConfig).GenArgs()...) 55 | 56 | return p 57 | } 58 | 59 | // Start 启动客户端 60 | func (p *PySSRClient) Start() error { 61 | // 使用pkexec在gui程序中请求权限 62 | args := make([]string, len(p.binArgs)) 63 | copy(args, p.binArgs) 64 | args = append(args, "-d", "start") 65 | cmd := exec.Command("pkexec", args...) 66 | return cmd.Run() 67 | } 68 | 69 | // Restart 重新启动客户端 70 | func (p *PySSRClient) Restart() error { 71 | args := make([]string, len(p.binArgs)) 72 | copy(args, p.binArgs) 73 | args = append(args, "-d", "restart") 74 | cmd := exec.Command("pkexec", args...) 75 | return cmd.Run() 76 | } 77 | 78 | // Stop 停止客户端 79 | func (p *PySSRClient) Stop() error { 80 | args := make([]string, len(p.binArgs)) 81 | copy(args, p.binArgs) 82 | args = append(args, "-d", "stop") 83 | cmd := exec.Command("pkexec", args...) 84 | return cmd.Run() 85 | } 86 | 87 | // IsRunning 客户端正在运行返回nil 88 | // INFO: 如果两个不同客户端进程使用了相同的端口号,则会导致pid-file无法删除,致使判断错误 89 | func (p *PySSRClient) IsRunning() error { 90 | if err := syscall.Access(p.conf.PidFilePath(), syscall.F_OK); err != nil { 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | 97 | // ConnectionCheck 检查代理是否可用,不可用则返回error 98 | func (p *PySSRClient) ConnectionCheck(timeout time.Duration) error { 99 | proxyURL, err := url.Parse("socks5://" + p.conf.LocalAddr() + ":" + p.conf.LocalPort()) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | client := &http.Client{ 105 | Timeout: timeout, 106 | } 107 | client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} 108 | 109 | request, err := http.NewRequest("GET", urls.ProxyTestPath, nil) 110 | if err != nil { 111 | return err 112 | } 113 | resp, err := client.Do(request) 114 | if err != nil { 115 | return err 116 | } 117 | defer resp.Body.Close() 118 | 119 | if resp.StatusCode != http.StatusOK { 120 | info := fmt.Sprintf("Get a wrong status code: %v", resp.StatusCode) 121 | return errors.New(info) 122 | } 123 | 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /pyclient/pyssrclient_config.go: -------------------------------------------------------------------------------- 1 | package pyclient 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io/ioutil" 7 | "os" 8 | "regexp" 9 | "strconv" 10 | 11 | "schannel-qt5/config" 12 | "schannel-qt5/ssr" 13 | ) 14 | 15 | var ( 16 | defaultPidFile = "/tmp/ssr_pyclient.pid" 17 | defaultPort = "1080" 18 | defaultAddr = "127.0.0.1" 19 | ) 20 | 21 | // ClientConfig pyssrclient的本地配置 22 | type ClientConfig struct { 23 | // 本地端口和ip(default: 127.0.0.1:1080) 24 | Addr string `json:"local_addr,omitempty"` 25 | Port string `json:"local_port,omitempty"` 26 | 27 | // fast-open 需要linux 3.7+(default: false) 28 | IsFastOpen bool `json:"fast-open,omitempty"` 29 | 30 | // pidfile存放位置(default: /tmp/ssr_client.pid) 31 | PidFile string `json:"pid-file,omitempty"` 32 | } 33 | 34 | func init() { 35 | // 注册到config生成器 36 | ssr.SetClientConfigMaker("python", config.ClientConfigMaker(newClientConfig)) 37 | } 38 | 39 | // newClientConfig 生成config对象 40 | func newClientConfig() config.ClientConfig { 41 | return &ClientConfig{} 42 | } 43 | 44 | // 实现ClientConfigGetter 45 | func (c *ClientConfig) LocalPort() string { 46 | if c.Port == "" { 47 | return defaultPort 48 | } 49 | 50 | return c.Port 51 | } 52 | 53 | func (c *ClientConfig) LocalAddr() string { 54 | if c.Addr == "" { 55 | return defaultAddr 56 | } 57 | 58 | return c.Addr 59 | } 60 | 61 | func (c *ClientConfig) FastOpen() bool { 62 | return c.IsFastOpen 63 | } 64 | 65 | func (c *ClientConfig) PidFilePath() string { 66 | if c.PidFile == "" { 67 | return defaultPidFile 68 | } 69 | 70 | return c.PidFile 71 | } 72 | 73 | func (c *ClientConfig) Load(path string) error { 74 | f, err := os.Open(path) 75 | if err != nil { 76 | return err 77 | } 78 | defer f.Close() 79 | 80 | data, err := ioutil.ReadAll(f) 81 | if err != nil { 82 | return err 83 | } 84 | err = json.Unmarshal(data, c) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func (c *ClientConfig) Store(path string) error { 93 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664) 94 | if err != nil { 95 | return err 96 | } 97 | defer f.Close() 98 | 99 | // 格式化成易于阅读的形式 100 | data, err := json.MarshalIndent(c, "", "\t") 101 | if err != nil { 102 | return err 103 | } 104 | f.Write(data) 105 | 106 | return nil 107 | } 108 | 109 | // 实现ClientConfigSetter 110 | // SetLocalPort 设置本地端口,端口不能大于65535且不能为0 111 | func (c *ClientConfig) SetLocalPort(port string) error { 112 | i, err := strconv.Atoi(port) 113 | if err != nil { 114 | return err 115 | } else if i > 65535 || i == 0 { 116 | return errors.New("port over range") 117 | } 118 | 119 | c.Port = port 120 | return nil 121 | } 122 | 123 | func (c *ClientConfig) SetFastOpen(isFOP bool) { 124 | c.IsFastOpen = isFOP 125 | } 126 | 127 | // SetPidFilePath 设置pidfile存放路径,需要为绝对路径 128 | func (c *ClientConfig) SetPidFilePath(path string) error { 129 | jpath := config.JSONPath{Data: path} 130 | if _, err := jpath.AbsPath(); err != nil { 131 | return err 132 | } 133 | 134 | c.PidFile = path 135 | return nil 136 | } 137 | 138 | // SetLocalAddr 检查并设置要bind的本地ip 139 | // 暂时只支持IPv4 140 | func (c *ClientConfig) SetLocalAddr(addr string) error { 141 | IP := regexp.MustCompile(`^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`) 142 | if !IP.MatchString(addr) { 143 | return errors.New("not a valid ip addr") 144 | } 145 | 146 | c.Addr = addr 147 | return nil 148 | } 149 | 150 | // GenArgs 根据config对象生成命令行参数选项 151 | func (c *ClientConfig) GenArgs() []string { 152 | args := make([]string, 0) 153 | args = append(args, "-b", c.LocalAddr()) 154 | args = append(args, "-l", c.LocalPort()) 155 | args = append(args, "--pid-file", c.PidFilePath()) 156 | if c.IsFastOpen { 157 | args = append(args, "--fast-open") 158 | } 159 | 160 | return args 161 | } 162 | -------------------------------------------------------------------------------- /pyclient/pyssrclient_config_test.go: -------------------------------------------------------------------------------- 1 | package pyclient 2 | 3 | import ( 4 | "testing" 5 | 6 | "strings" 7 | ) 8 | 9 | func TestClientConfigDefault(t *testing.T) { 10 | conf := &ClientConfig{} 11 | if conf.LocalPort() != defaultPort { 12 | t.Errorf("wrong default port\n") 13 | } 14 | 15 | if conf.LocalAddr() != defaultAddr { 16 | t.Errorf("wrong default addr\n") 17 | } 18 | 19 | if conf.PidFilePath() != defaultPidFile { 20 | t.Errorf("wrong default pid-file\n") 21 | } 22 | 23 | if conf.FastOpen() { 24 | t.Errorf("wrong default fast-open") 25 | } 26 | } 27 | 28 | func TestClientConfigSetLocalPort(t *testing.T) { 29 | conf := &ClientConfig{} 30 | trueData := []string{ 31 | "200", 32 | "2000", 33 | "1080", 34 | "8888", 35 | } 36 | wrongData := []string{ 37 | "", 38 | "0", 39 | "test", 40 | "端口", 41 | "99999", 42 | } 43 | 44 | for _, v := range trueData { 45 | if err := conf.SetLocalPort(v); err != nil { 46 | t.Errorf("set port failed: %v\n", v) 47 | } else if conf.LocalPort() != v { 48 | format := "set port wrong: have %v; want %v\n" 49 | t.Errorf(format, conf.LocalPort(), v) 50 | } 51 | } 52 | 53 | for _, v := range wrongData { 54 | if err := conf.SetLocalPort(v); err == nil { 55 | t.Errorf("set wrong port but didn't fail: %v\n", v) 56 | } 57 | } 58 | } 59 | 60 | func TestClientConfigSetFastOpen(t *testing.T) { 61 | conf := &ClientConfig{} 62 | 63 | // fast-open只有两种值,分别进行测试 64 | conf.SetFastOpen(true) 65 | if !conf.FastOpen() { 66 | t.Errorf("set fast-open failed") 67 | } 68 | 69 | conf.SetFastOpen(false) 70 | if conf.FastOpen() { 71 | t.Errorf("set fast-open failed") 72 | } 73 | } 74 | 75 | func TestClientConfigSetLocalAddr(t *testing.T) { 76 | conf := &ClientConfig{} 77 | trueData := []string{ 78 | "0.0.0.0", 79 | "127.0.0.1", 80 | "10.1.1.2", 81 | "210.199.200.201", 82 | } 83 | wrongData := []string{ 84 | "", 85 | "12345", 86 | "12.22.33.", 87 | "255.256.0.1", 88 | "test", 89 | } 90 | 91 | for _, v := range trueData { 92 | if err := conf.SetLocalAddr(v); err != nil { 93 | t.Errorf("set addr failed: %v\n", v) 94 | } else if conf.LocalAddr() != v { 95 | format := "set addr wrong: have %v; want %v\n" 96 | t.Errorf(format, conf.LocalAddr(), v) 97 | } 98 | } 99 | 100 | for _, v := range wrongData { 101 | if err := conf.SetLocalAddr(v); err == nil { 102 | t.Errorf("set wrong addr but didn't fail: %v\n", v) 103 | } 104 | } 105 | } 106 | 107 | func TestClientConfigSetPidFilePath(t *testing.T) { 108 | conf := &ClientConfig{} 109 | trueData := []string{"/tmp/a.pid", "~/.tmp/a.pid"} 110 | wrongData := []string{"", "tmp/a.pid", "a.pid"} 111 | 112 | for _, v := range trueData { 113 | if err := conf.SetPidFilePath(v); err != nil { 114 | t.Errorf("set pidfile failed: %v\n", v) 115 | } else if conf.PidFilePath() != v { 116 | format := "set pidfile wrong: have %v; want %v\n" 117 | t.Errorf(format, conf.PidFilePath(), v) 118 | } 119 | } 120 | 121 | for _, v := range wrongData { 122 | if err := conf.SetPidFilePath(v); err == nil { 123 | format := "set wrong pidfile but didn't fail: %v\n" 124 | t.Errorf(format, v) 125 | } 126 | } 127 | } 128 | 129 | func TestClientConfigGenArgs(t *testing.T) { 130 | testData := []*struct { 131 | // config对象 132 | c *ClientConfig 133 | // 生成的args组合,通过join组合 134 | args string 135 | }{ 136 | { 137 | c: &ClientConfig{ 138 | Addr: "172.17.0.1", 139 | Port: "8888", 140 | IsFastOpen: false, 141 | PidFile: "/tmp/a.pid", 142 | }, 143 | args: "-b 172.17.0.1 -l 8888 --pid-file /tmp/a.pid", 144 | }, 145 | { 146 | c: &ClientConfig{ 147 | Addr: "", 148 | Port: "", 149 | IsFastOpen: false, 150 | PidFile: "", 151 | }, 152 | args: "-b " + defaultAddr + 153 | " -l " + defaultPort + 154 | " --pid-file " + defaultPidFile, 155 | }, 156 | { 157 | c: &ClientConfig{ 158 | Addr: "172.17.0.1", 159 | Port: "8888", 160 | IsFastOpen: true, 161 | PidFile: "/tmp/a.pid", 162 | }, 163 | args: "-b 172.17.0.1 " + 164 | "-l 8888 " + 165 | "--pid-file /tmp/a.pid " + 166 | "--fast-open", 167 | }, 168 | } 169 | 170 | for _, v := range testData { 171 | args := v.c.GenArgs() 172 | if strings.Join(args, " ") != v.args { 173 | t.Errorf("genargs failed:\nArgs: %v\n", args) 174 | } 175 | } 176 | } 177 | 178 | func TestClientConfigLoad(t *testing.T) { 179 | testData := []*struct { 180 | // load文件路径 181 | file string 182 | // 与load后的config对象进行比较 183 | sample ClientConfig 184 | }{ 185 | { 186 | file: "testdata/test_config.json", 187 | sample: ClientConfig{ 188 | Addr: "172.17.0.1", 189 | Port: "1080", 190 | IsFastOpen: true, 191 | PidFile: "/tmp/test.pid", 192 | }, 193 | }, 194 | { 195 | file: "testdata/test_empty.json", 196 | sample: ClientConfig{ 197 | Addr: "", 198 | Port: "", 199 | IsFastOpen: false, 200 | PidFile: "", 201 | }, 202 | }, 203 | { 204 | file: "testdata/test_default.json", 205 | sample: ClientConfig{ 206 | Addr: "", 207 | Port: "1081", 208 | IsFastOpen: true, 209 | PidFile: "", 210 | }, 211 | }, 212 | } 213 | 214 | for _, v := range testData { 215 | conf := ClientConfig{} 216 | err := conf.Load(v.file) 217 | if err != nil { 218 | t.Error(err) 219 | } 220 | 221 | if conf != v.sample { 222 | format := "load failed:\n\thave: %v\n\twant: %v\n" 223 | t.Errorf(format, conf, v.sample) 224 | } 225 | } 226 | } 227 | 228 | func TestClientConfigStore(t *testing.T) { 229 | testData := []*struct { 230 | file string 231 | conf *ClientConfig 232 | }{ 233 | { 234 | file: "/tmp/empty_config.json", 235 | conf: &ClientConfig{}, 236 | }, 237 | { 238 | file: "/tmp/default_config.json", 239 | conf: &ClientConfig{ 240 | Addr: "", 241 | Port: "1081", 242 | IsFastOpen: true, 243 | PidFile: "", 244 | }, 245 | }, 246 | { 247 | file: "/tmp/full_config.json", 248 | conf: &ClientConfig{ 249 | Addr: "172.12.0.1", 250 | Port: "1080", 251 | IsFastOpen: true, 252 | PidFile: "/tmp/test.pid", 253 | }, 254 | }, 255 | } 256 | 257 | for _, v := range testData { 258 | if err := v.conf.Store(v.file); err != nil { 259 | t.Errorf("store failed: %v\n", err) 260 | } 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /pyclient/pyssrclient_test.go: -------------------------------------------------------------------------------- 1 | package pyclient 2 | 3 | import ( 4 | "testing" 5 | 6 | "os" 7 | "time" 8 | 9 | "schannel-qt5/config" 10 | ) 11 | 12 | var ( 13 | // 测试用空config对象 14 | dummyClientConfig = &ClientConfig{} 15 | // 测试用用户配置 16 | dummyUserConfig = &config.UserConfig{ 17 | Proxy: config.JSONProxy{}, 18 | LogFile: config.JSONPath{}, 19 | SSRNodeConfigPath: config.JSONPath{Data: os.Getenv("SSRNODECONFIG")}, 20 | SSRClientConfigPath: config.JSONPath{}, 21 | SSRBin: config.JSONPath{Data: os.Getenv("SSRBIN")}, 22 | SSRClientConfig: dummyClientConfig, 23 | } 24 | // 默认的文件路径 25 | defaultBin = "~/shadowsocksr/shadowsocks/local.py" 26 | defaultNode = "~/ssr.json" 27 | ) 28 | 29 | // checkUserConfig 检查用户配置 30 | // 如果未设置$SSRBIN和$SSRNODECONFIG,则使用默认值替代 31 | func checkUserConfig(t *testing.T) { 32 | if dummyUserConfig.SSRBin.String() == "" { 33 | t.Log("not set $SSRBIN, use default\n") 34 | dummyUserConfig.SSRBin.Data = defaultBin 35 | } 36 | 37 | if dummyUserConfig.SSRNodeConfigPath.String() == "" { 38 | t.Log("not set $SSRNODECONFIG, use default\n") 39 | dummyUserConfig.SSRNodeConfigPath.Data = defaultNode 40 | } 41 | } 42 | 43 | func TestPySSRClient(t *testing.T) { 44 | // 先对dummyUserConfig初始化 45 | checkUserConfig(t) 46 | client := newPySSRClient(dummyUserConfig) 47 | if client == nil { 48 | t.Error("newPySSRClient failed: ", dummyUserConfig) 49 | } 50 | 51 | if err := client.Start(); err != nil { 52 | t.Errorf("start client failed: %v\n", err) 53 | } 54 | 55 | if err := client.Restart(); err != nil { 56 | t.Errorf("restart client failed: %v\n", err) 57 | } 58 | 59 | if err := client.Stop(); err != nil { 60 | t.Errorf("stop client failed: %v\n", err) 61 | } 62 | } 63 | 64 | func TestPySSRClientIsRunning(t *testing.T) { 65 | checkUserConfig(t) 66 | client := newPySSRClient(dummyUserConfig) 67 | if client == nil { 68 | t.Error("newPySSRClient failed: ", dummyUserConfig) 69 | } 70 | 71 | if err := client.Start(); err != nil { 72 | t.Errorf("start client failed: %v\n", err) 73 | } 74 | 75 | // 测试是否正在运行 76 | if err := client.IsRunning(); err != nil { 77 | t.Fatalf("test is running error: %v\n", err) 78 | } 79 | 80 | if err := client.Stop(); err != nil { 81 | t.Errorf("stop client failed: %v\n", err) 82 | } 83 | 84 | // 测试是否已经关闭 85 | if err := client.IsRunning(); err == nil { 86 | t.Fatalf("test not run error: %v\n", err) 87 | } 88 | } 89 | 90 | func TestPySSRClientConnectionCheck(t *testing.T) { 91 | checkUserConfig(t) 92 | client := newPySSRClient(dummyUserConfig) 93 | if client == nil { 94 | t.Error("newPySSRClient failed: ", dummyUserConfig) 95 | } 96 | 97 | // 打开代理 98 | if err := client.Start(); err != nil { 99 | t.Errorf("start client failed: %v\n", err) 100 | } 101 | 102 | if err := client.ConnectionCheck(10 * time.Second); err != nil { 103 | t.Errorf("connect check failed: %v\n", err) 104 | } 105 | 106 | // 测试结束关闭代理 107 | if err := client.Stop(); err != nil { 108 | t.Errorf("stop client failed: %v\n", err) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /pyclient/testdata/test_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "local_addr": "172.17.0.1", 3 | "local_port": "1080", 4 | "fast-open": true, 5 | "pid-file": "/tmp/test.pid" 6 | } -------------------------------------------------------------------------------- /pyclient/testdata/test_default.json: -------------------------------------------------------------------------------- 1 | { 2 | "local_port": "1081", 3 | "fast-open": true 4 | } -------------------------------------------------------------------------------- /pyclient/testdata/test_empty.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /resource.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | image/icon.svg 4 | image/download.svg 5 | image/ic_copy_link.svg 6 | image/close.png 7 | image/Removefixed.svg 8 | 9 | 10 | emoji-flags/data.json 11 | 12 | 13 | -------------------------------------------------------------------------------- /screenshots/charts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/screenshots/charts.png -------------------------------------------------------------------------------- /screenshots/invoices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/screenshots/invoices.png -------------------------------------------------------------------------------- /screenshots/invoiceview.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/screenshots/invoiceview.webp -------------------------------------------------------------------------------- /screenshots/logging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/screenshots/logging.png -------------------------------------------------------------------------------- /screenshots/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/screenshots/login.png -------------------------------------------------------------------------------- /screenshots/nodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/screenshots/nodes.png -------------------------------------------------------------------------------- /screenshots/service1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/screenshots/service1.webp -------------------------------------------------------------------------------- /screenshots/service2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/screenshots/service2.webp -------------------------------------------------------------------------------- /screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apocelipes/schannel-qt5/9beddec07645de2ff8d2e487bf0302e25a67a93f/screenshots/settings.png -------------------------------------------------------------------------------- /ssr/ssr.go: -------------------------------------------------------------------------------- 1 | package ssr 2 | 3 | import ( 4 | "time" 5 | 6 | "schannel-qt5/config" 7 | ) 8 | 9 | // Launcher 用于控制ssr客户端 10 | type Launcher interface { 11 | // Start 打开客户端 12 | Start() error 13 | // Restart 重启客户端 14 | Restart() error 15 | // Stop 关闭客户端 16 | Stop() error 17 | // IsRunning 客户端是否正在运行 18 | IsRunning() error 19 | // ConnectionCheck 检查ssr代理是否可用 20 | ConnectionCheck(timeout time.Duration) error 21 | } 22 | 23 | // LauncherMaker 生成Launcher的工厂函数 24 | type LauncherMaker func(*config.UserConfig) Launcher 25 | 26 | var ( 27 | // 保存注册的LauncherMaker 28 | launchers = make(map[string]LauncherMaker) 29 | // ssr config注册获取 30 | configs = make(map[string]config.ClientConfigMaker) 31 | ) 32 | 33 | // SetLuancherMaker 注册Launcher生成器 34 | func SetLuancherMaker(name string, maker LauncherMaker) { 35 | if name == "" || maker == nil { 36 | panic("SetLauncher error: wrong name or LuancherMaker") 37 | } 38 | 39 | launchers[name] = maker 40 | } 41 | 42 | // SetClientConfigMaker 注册ClientConfig生成器 43 | func SetClientConfigMaker(name string, maker config.ClientConfigMaker) { 44 | if name == "" || maker == nil { 45 | panic("SetClientConfigMaker error: wrong name or ClientConfigMaker") 46 | } 47 | 48 | configs[name] = maker 49 | } 50 | 51 | // NewLauncher 返回由name指定的Launcher生成器使用config.UserConfig生成的Launcher 52 | func NewLauncher(name string, conf *config.UserConfig) Launcher { 53 | maker, ok := launchers[name] 54 | if !ok { 55 | return nil 56 | } 57 | 58 | return maker(conf) 59 | } 60 | 61 | // NewClientConfig 根据名字返回默认值的ClientConfig 62 | func NewClientConfig(name string) config.ClientConfig { 63 | maker, ok := configs[name] 64 | if !ok { 65 | return nil 66 | } 67 | 68 | return maker() 69 | } 70 | -------------------------------------------------------------------------------- /urls/urls.go: -------------------------------------------------------------------------------- 1 | package urls 2 | 3 | const ( 4 | // RootPath 网站主URL 5 | RootPath = `https://sgchannel.cloud/` 6 | // AuthPath 登录页面的URL 7 | AccountPath = RootPath + `clientarea.php` 8 | // ServiceListPath 服务列表URL 9 | ServiceListPath = AccountPath + `?action=services` 10 | // LoginPath 登录验证的URL 11 | LoginPath = RootPath + `dologin.php` 12 | // InvoicePath 账单列表的URL 13 | InvoicePath = AccountPath + `?action=invoices` 14 | // 测试代理的URL 15 | ProxyTestPath = `https://golang.org` 16 | ) 17 | -------------------------------------------------------------------------------- /widgets/account_item.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "github.com/therecipe/qt/core" 5 | "github.com/therecipe/qt/gui" 6 | "github.com/therecipe/qt/widgets" 7 | ) 8 | 9 | // AccountItem 显示账户名和删除按钮,由QComboBox使用 10 | type AccountItem struct { 11 | widgets.QWidget 12 | 13 | // 选中时显示账户名 14 | _ func(string) `signal:"showAccount"` 15 | // 删除所选用户 16 | _ func(string) `signal:"removeAccount"` 17 | 18 | accountName *widgets.QLabel 19 | delButton *widgets.QPushButton 20 | userName string 21 | // 用户是否进行左键单击 22 | mousePress bool 23 | } 24 | 25 | func NewAccountItem2(user string) *AccountItem { 26 | item := NewAccountItem(nil, 0) 27 | item.userName = user 28 | item.InitUI() 29 | return item 30 | } 31 | 32 | const ( 33 | // 留给伸缩因子的空间 34 | spaceStreth = 40 35 | // 边界宽度 36 | leftBorder = 5 37 | rightBorder = 5 38 | topBorder = 5 39 | bottomBorder = 5 40 | ) 41 | 42 | func (item *AccountItem) InitUI() { 43 | item.accountName = widgets.NewQLabel2(item.userName, nil, 0) 44 | // account设置为button的3倍 45 | accountSizePolicy := item.accountName.SizePolicy() 46 | accountSizePolicy.SetHorizontalPolicy(widgets.QSizePolicy__Expanding) 47 | accountSizePolicy.SetHorizontalStretch(3) 48 | item.accountName.SetSizePolicy(accountSizePolicy) 49 | 50 | delIcon := widgets.QApplication_Style().StandardIcon(widgets.QStyle__SP_DialogCloseButton, nil, nil) 51 | item.delButton = widgets.NewQPushButton3(delIcon, "", nil) 52 | // 设置背景透明,x11上需要指定rgba 53 | item.delButton.SetStyleSheet("QPushButton{background:rgba(0,0,0,0);border:1px solid rgba(0,0,0,0);}") 54 | // 设置icon大小 55 | iconHeight := item.accountName.FontMetrics().Height() 56 | iconWidth := iconHeight 57 | item.delButton.SetIconSize(core.NewQSize2(iconWidth, iconHeight)) 58 | btnSizePolicy := item.delButton.SizePolicy() 59 | btnSizePolicy.SetHorizontalStretch(1) 60 | item.delButton.SetSizePolicy(btnSizePolicy) 61 | item.delButton.ConnectClicked(func(_ bool) { 62 | item.RemoveAccount(item.userName) 63 | }) 64 | 65 | mainLayout := widgets.NewQHBoxLayout() 66 | mainLayout.AddWidget(item.accountName, 0, core.Qt__AlignLeft) 67 | mainLayout.AddStretch(0) 68 | mainLayout.AddWidget(item.delButton, 0, 0) 69 | mainLayout.SetSpacing(5) 70 | mainLayout.SetContentsMargins(leftBorder, topBorder, rightBorder, bottomBorder) 71 | item.SetLayout(mainLayout) 72 | 73 | // 处理click 74 | item.ConnectMousePressEvent(func(event *gui.QMouseEvent) { 75 | if event.Button() == core.Qt__LeftButton { 76 | // 记录左键按下 77 | item.mousePress = true 78 | return 79 | } 80 | 81 | item.QWidget.MousePressEventDefault(event) 82 | }) 83 | item.ConnectMouseReleaseEvent(func(event *gui.QMouseEvent) { 84 | if item.mousePress { 85 | item.ShowAccount(item.userName) 86 | item.mousePress = false 87 | return 88 | } 89 | 90 | item.QWidget.MouseReleaseEventDefault(event) 91 | }) 92 | 93 | // 设置大小 94 | item.ConnectSizeHint(func() *core.QSize { 95 | pointSize := item.accountName.Font().PointSize() 96 | textLength := len(item.userName) * pointSize 97 | // left + text + spacing + stretch + button + right 98 | width := leftBorder + textLength + 5*2 + spaceStreth + textLength/3 + rightBorder 99 | // top + text + bottom 100 | height := topBorder + item.accountName.FontMetrics().Height() + bottomBorder 101 | return core.NewQSize2(width, height) 102 | }) 103 | 104 | // 设置自身大小策略 105 | sizePolicy := item.SizePolicy() 106 | sizePolicy.SetHorizontalPolicy(widgets.QSizePolicy__MinimumExpanding) 107 | } 108 | -------------------------------------------------------------------------------- /widgets/charts_dialog.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/astaxie/beego/orm" 9 | "github.com/therecipe/qt/charts" 10 | "github.com/therecipe/qt/core" 11 | "github.com/therecipe/qt/gui" 12 | "github.com/therecipe/qt/widgets" 13 | 14 | "schannel-qt5/models" 15 | "schannel-qt5/parser" 16 | ) 17 | 18 | // ChartsDialog 显示上传下载对比pie chart和使用趋势line charts 19 | type ChartsDialog struct { 20 | widgets.QDialog 21 | 22 | // 用于从数据库获取相关服务的数据 23 | user string 24 | service string 25 | amounts []*models.UsedAmount 26 | // charts color 27 | downloadColor *gui.QColor 28 | uploadColor *gui.QColor 29 | // 统计图的边界日期 30 | // TODO 未来可以自行选择日期,目前以GetCurrentDay为基准 31 | date time.Time 32 | logger *log.Logger 33 | } 34 | 35 | // NewChartsDialog2 创建统计图表对话框,创建失败会将信息写入日志 36 | // 对话框不能放大或拉伸,会导致charts无法正常显示 37 | func NewChartsDialog2(user, service string, logger *log.Logger, parent widgets.QWidget_ITF) *ChartsDialog { 38 | dialog := NewChartsDialog(parent, 0) 39 | dialog.user = user 40 | dialog.service = service 41 | dialog.date = parser.GetCurrentDay() 42 | dialog.logger = logger 43 | dialog.downloadColor = gui.NewQColor6("red") 44 | dialog.uploadColor = gui.NewQColor6("orange") 45 | 46 | db := orm.NewOrm() 47 | var err error 48 | dialog.amounts, err = models.GetRecentUsedAmount(db, dialog.user, dialog.service, dialog.date) 49 | if err != nil { 50 | dialog.logger.Fatalf("ChartsDialog get recent data error: %v\n", err) 51 | } 52 | dialog.InitUI() 53 | 54 | return dialog 55 | } 56 | 57 | // InitUI 生成图表并显示 58 | func (dialog *ChartsDialog) InitUI() { 59 | pieChart := dialog.CreatePieChart() 60 | uploadLineChart := dialog.CreateUploadLineChart() 61 | downloadLineChart := dialog.CreateDownloadLineChart() 62 | 63 | rightLayout := widgets.NewQVBoxLayout() 64 | rightLayout.AddWidget(downloadLineChart, 0, 0) 65 | rightLayout.AddWidget(uploadLineChart, 0, 0) 66 | mainLayout := widgets.NewQHBoxLayout() 67 | mainLayout.AddWidget(pieChart, 0, 0) 68 | mainLayout.AddLayout(rightLayout, 0) 69 | dialog.SetLayout(mainLayout) 70 | // 设置dialog大小,保证charts能正常绘制 71 | dialog.SetMinimumHeight(700) 72 | dialog.SetMinimumWidth(900) 73 | // 非模态dialog,设置关闭后销毁dialog对象 74 | dialog.SetAttribute(core.Qt__WA_DeleteOnClose, true) 75 | dialog.SetWindowTitle("数据用量统计") 76 | } 77 | 78 | // CreatePieChart 生成upload/download饼图 79 | func (dialog *ChartsDialog) CreatePieChart() *charts.QChartView { 80 | // amounts倒序排列,0是今天 81 | todayAmount := dialog.amounts[0] 82 | pie := charts.NewQPieSeries(nil) 83 | pie.Append3("下载", float64(todayAmount.Download)) 84 | pie.Append3("上传", float64(todayAmount.Upload)) 85 | items := pie.Slices() 86 | downloadPer, uploadPer := computePercent(todayAmount.Download, todayAmount.Upload) 87 | items[0].SetColor(dialog.downloadColor) 88 | items[0].SetLabelVisible(true) 89 | items[0].SetLabelPosition(charts.QPieSlice__LabelOutside) 90 | items[0].SetLabel(fmt.Sprintf("下载:%.2f%%", downloadPer)) 91 | items[1].SetColor(dialog.uploadColor) 92 | items[1].SetLabelVisible(true) 93 | items[1].SetLabelPosition(charts.QPieSlice__LabelOutside) 94 | items[1].SetLabel(fmt.Sprintf("上传:%.2f%%", uploadPer)) 95 | 96 | chart := charts.NewQChart(nil, 0) 97 | chart.AddSeries(pie) 98 | chart.SetTitle("上传/下载对比图") 99 | chart.Legend().Hide() 100 | chartView := charts.NewQChartView2(chart, nil) 101 | chartView.SetRenderHints(gui.QPainter__Antialiasing) 102 | return chartView 103 | } 104 | 105 | // computePercent 计算download和upload的百分比 106 | func computePercent(download, upload int) (float64, float64) { 107 | total := float64(download + upload) 108 | return float64(download) / total * 100, float64(upload) / total * 100 109 | } 110 | 111 | // CreateUploadLineChart 生成upload流量使用情况趋势图 112 | func (dialog *ChartsDialog) CreateUploadLineChart() *charts.QChartView { 113 | line := charts.NewQLineSeries(nil) 114 | line.SetName("上传") 115 | line.SetColor(dialog.uploadColor) 116 | // dataSet用于计算计量单位和range 117 | dataSet := make([]int, 0, len(dialog.amounts)) 118 | for _, v := range dialog.amounts { 119 | dataSet = append(dataSet, v.Upload) 120 | } 121 | ratio, unit := computeSizeUnit(dataSet) 122 | 123 | for i := len(dialog.amounts) - 1; i >= 0; i-- { 124 | date := dialog.amounts[i].Date 125 | qDate := core.NewQDate3(date.Year(), int(date.Month()), date.Day()) 126 | datetime := core.NewQDateTime2(qDate) 127 | value := float64(dialog.amounts[i].Upload) / float64(ratio) 128 | line.Append(float64(datetime.ToMSecsSinceEpoch()), value) 129 | } 130 | chart := charts.NewQChart(nil, 0) 131 | chart.AddSeries(line) 132 | chart.SetTitle("上传使用趋势(月底清零)") 133 | 134 | axisX := charts.NewQDateTimeAxis(nil) 135 | axisX.SetTickCount(5) 136 | axisX.SetFormat("MM-dd") 137 | axisX.SetTitleText("日期") 138 | chart.AddAxis(axisX, core.Qt__AlignBottom) 139 | 140 | axisY := charts.NewQValueAxis(nil) 141 | axisY.SetTitleText(fmt.Sprintf("上传(%s)", unit)) 142 | axisY.SetRange(computeRange(dataSet, ratio, unit)) 143 | chart.AddAxis(axisY, core.Qt__AlignRight) 144 | 145 | line.AttachAxis(axisX) 146 | line.AttachAxis(axisY) 147 | chartView := charts.NewQChartView2(chart, nil) 148 | chartView.SetRenderHints(gui.QPainter__Antialiasing) 149 | return chartView 150 | } 151 | 152 | // CreateDownloadLineChart 生成download流量使用情况趋势图 153 | func (dialog *ChartsDialog) CreateDownloadLineChart() *charts.QChartView { 154 | line := charts.NewQLineSeries(nil) 155 | line.SetName("下载") 156 | line.SetColor(dialog.downloadColor) 157 | // dataSet用于计算计量单位和range 158 | dataSet := make([]int, 0, len(dialog.amounts)) 159 | for _, v := range dialog.amounts { 160 | dataSet = append(dataSet, v.Download) 161 | } 162 | ratio, unit := computeSizeUnit(dataSet) 163 | 164 | for i := len(dialog.amounts) - 1; i >= 0; i-- { 165 | date := dialog.amounts[i].Date 166 | qDate := core.NewQDate3(date.Year(), int(date.Month()), date.Day()) 167 | datetime := core.NewQDateTime2(qDate) 168 | value := float64(dialog.amounts[i].Download) / float64(ratio) 169 | line.Append(float64(datetime.ToMSecsSinceEpoch()), value) 170 | } 171 | chart := charts.NewQChart(nil, 0) 172 | chart.AddSeries(line) 173 | chart.SetTitle("下载使用趋势(月底清零)") 174 | 175 | axisX := charts.NewQDateTimeAxis(nil) 176 | axisX.SetTickCount(5) 177 | axisX.SetFormat("MM-dd") 178 | axisX.SetTitleText("日期") 179 | chart.AddAxis(axisX, core.Qt__AlignBottom) 180 | 181 | axisY := charts.NewQValueAxis(nil) 182 | axisY.SetTitleText(fmt.Sprintf("下载(%s)", unit)) 183 | axisY.SetRange(computeRange(dataSet, ratio, unit)) 184 | chart.AddAxis(axisY, core.Qt__AlignLeft) 185 | 186 | line.AttachAxis(axisX) 187 | line.AttachAxis(axisY) 188 | chartView := charts.NewQChartView2(chart, nil) 189 | chartView.SetRenderHints(gui.QPainter__Antialiasing) 190 | return chartView 191 | } 192 | -------------------------------------------------------------------------------- /widgets/client_config_widget.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/therecipe/qt/widgets" 8 | 9 | "schannel-qt5/config" 10 | ) 11 | 12 | var ( 13 | // 代理的可用协议 14 | protocols = []string{ 15 | "http", 16 | "https", 17 | "socks5", 18 | } 19 | ) 20 | 21 | // schannel-qt5客户端配置控件 22 | type ClientConfigWidget struct { 23 | widgets.QWidget 24 | 25 | // 配置改变后发送通知 26 | _ func() `signal:"valueChanged"` 27 | 28 | // client设置 29 | logFile *widgets.QLineEdit 30 | logFileMsg *ColorLabel 31 | 32 | // ssr设置 33 | nodeConfigPath, ssrConfigPath, binPath *widgets.QLineEdit 34 | nodeConfigPathMsg, ssrConfigPathMsg, binPathMsg *ColorLabel 35 | 36 | // 代理设置 37 | proxy *widgets.QLineEdit 38 | proxyType *widgets.QComboBox 39 | proxyBox *widgets.QGroupBox 40 | proxyMsg *ColorLabel 41 | 42 | // 配置数据和接口 43 | conf *config.UserConfig 44 | } 45 | 46 | // 根据UserConfig创建ClientConfigWidget 47 | func NewClientConfigWidget2(conf *config.UserConfig) *ClientConfigWidget { 48 | cw := NewClientConfigWidget(nil, 0) 49 | cw.conf = conf 50 | cw.InitUI() 51 | 52 | return cw 53 | } 54 | 55 | func (cw *ClientConfigWidget) InitUI() { 56 | // user设置布局 57 | userBox := widgets.NewQGroupBox2("客户端设置(重启生效)", nil) 58 | 59 | cw.logFile = widgets.NewQLineEdit2(cw.conf.LogFile.String(), nil) 60 | cw.logFile.SetPlaceholderText("日志文件保存路径") 61 | cw.logFile.ConnectTextChanged(func(_ string) { 62 | cw.ValueChanged() 63 | }) 64 | cw.logFileMsg = NewColorLabelWithColor("路径需要为绝对路径且不能为目录", "red") 65 | cw.logFileMsg.Hide() 66 | 67 | userLayout := widgets.NewQFormLayout(nil) 68 | userLayout.AddRow3("日志文件路径:", cw.logFile) 69 | userLayout.AddRow5(cw.logFileMsg) 70 | userBox.SetLayout(userLayout) 71 | 72 | // ssr设置布局 73 | ssrBox := widgets.NewQGroupBox2("ssr设置", nil) 74 | ssrLayout := widgets.NewQFormLayout(nil) 75 | cw.ssrConfigPath = widgets.NewQLineEdit2(cw.conf.SSRClientConfigPath.String(), nil) 76 | cw.ssrConfigPath.SetPlaceholderText("绝对路径") 77 | cw.ssrConfigPath.ConnectTextChanged(func(_ string) { 78 | cw.ValueChanged() 79 | }) 80 | cw.ssrConfigPathMsg = NewColorLabelWithColor("路径需要为绝对路径且不能为目录", "red") 81 | cw.ssrConfigPathMsg.Hide() 82 | ssrLayout.AddRow3("客户端配置路径:", cw.ssrConfigPath) 83 | ssrLayout.AddRow5(cw.ssrConfigPathMsg) 84 | 85 | cw.nodeConfigPath = widgets.NewQLineEdit2(cw.conf.SSRNodeConfigPath.String(), nil) 86 | cw.nodeConfigPath.SetPlaceholderText("绝对路径") 87 | cw.nodeConfigPath.ConnectTextChanged(func(_ string) { 88 | cw.ValueChanged() 89 | }) 90 | cw.nodeConfigPathMsg = NewColorLabelWithColor("路径需要为绝对路径且不能为目录", "red") 91 | cw.nodeConfigPathMsg.Hide() 92 | ssrLayout.AddRow3("节点配置路径:", cw.nodeConfigPath) 93 | ssrLayout.AddRow5(cw.nodeConfigPathMsg) 94 | 95 | cw.binPath = widgets.NewQLineEdit2(cw.conf.SSRBin.String(), nil) 96 | cw.binPath.SetPlaceholderText("绝对路径") 97 | cw.binPath.ConnectTextChanged(func(_ string) { 98 | cw.ValueChanged() 99 | }) 100 | cw.binPathMsg = NewColorLabelWithColor("路径需要为绝对路径且不能为目录", "red") 101 | cw.binPathMsg.Hide() 102 | ssrLayout.AddRow3("程序路径:", cw.binPath) 103 | ssrLayout.AddRow5(cw.binPathMsg) 104 | ssrBox.SetLayout(ssrLayout) 105 | 106 | // 对协议列表排序,方便查找 107 | sort.Strings(protocols) 108 | 109 | // proxy设置,可选 110 | cw.proxyBox = widgets.NewQGroupBox2("使用代理", nil) 111 | cw.proxyBox.SetCheckable(true) 112 | 113 | typeLabel := widgets.NewQLabel2("协议类型:", nil, 0) 114 | cw.proxyType = widgets.NewQComboBox(nil) 115 | cw.proxyType.AddItems(protocols) 116 | proxyLabel := widgets.NewQLabel2("代理服务器地址:", nil, 0) 117 | cw.proxy = widgets.NewQLineEdit(nil) 118 | cw.proxy.SetPlaceholderText("URL") 119 | cw.proxy.ConnectTextChanged(func(_ string) { 120 | cw.ValueChanged() 121 | }) 122 | cw.proxyMsg = NewColorLabelWithColor("不是合法的URL", "red") 123 | cw.proxyMsg.Hide() 124 | 125 | // 根据配置确定是否勾选代理设置 126 | if cw.conf.Proxy.String() != "" { 127 | proto, host := cw.splitProtoHost() 128 | // 显示配置的协议 129 | cw.proxyType.SetCurrentIndex(sort.SearchStrings(protocols, proto)) 130 | cw.proxy.SetText(host) 131 | cw.proxyBox.SetChecked(true) 132 | } else { 133 | cw.proxyBox.SetChecked(false) 134 | } 135 | 136 | typeLayout := widgets.NewQHBoxLayout() 137 | typeLayout.AddWidget(typeLabel, 0, 0) 138 | typeLayout.AddWidget(cw.proxyType, 0, 0) 139 | urlLayout := widgets.NewQHBoxLayout() 140 | urlLayout.AddWidget(proxyLabel, 0, 0) 141 | urlLayout.AddWidget(cw.proxy, 0, 0) 142 | 143 | proxyLayout := widgets.NewQVBoxLayout() 144 | proxyLayout.AddLayout(typeLayout, 0) 145 | proxyLayout.AddLayout(urlLayout, 0) 146 | proxyLayout.AddWidget(cw.proxyMsg, 0, 0) 147 | cw.proxyBox.SetLayout(proxyLayout) 148 | 149 | mainLayout := widgets.NewQVBoxLayout() 150 | mainLayout.AddWidget(userBox, 0, 0) 151 | mainLayout.AddWidget(ssrBox, 0, 0) 152 | mainLayout.AddWidget(cw.proxyBox, 0, 0) 153 | cw.SetLayout(mainLayout) 154 | } 155 | 156 | // splitProtoHost 分割返回协议和主机名 157 | func (w *ClientConfigWidget) splitProtoHost() (proto, host string) { 158 | data := strings.Split(w.conf.Proxy.String(), "://") 159 | proto = data[0] 160 | host = data[1] 161 | 162 | return 163 | } 164 | 165 | // UpdateClientConfig 更新UserClient,传递的为UserConfig的引用,可以直接修改 166 | func (cw *ClientConfigWidget) UpdateClientConfig() error { 167 | var err error 168 | var errRes error 169 | 170 | err = cw.validLogFile() 171 | if showErrorMsg(cw.logFileMsg, err) { 172 | errRes = err 173 | } 174 | 175 | err = cw.validSSRConfigPath() 176 | if showErrorMsg(cw.ssrConfigPathMsg, err) { 177 | errRes = err 178 | } 179 | 180 | err = cw.validNodeConfigPath() 181 | if showErrorMsg(cw.nodeConfigPathMsg, err) { 182 | errRes = err 183 | } 184 | 185 | err = cw.validBinPath() 186 | if showErrorMsg(cw.binPathMsg, err) { 187 | errRes = err 188 | } 189 | 190 | err = cw.validProxy() 191 | if showErrorMsg(cw.proxyMsg, err) { 192 | errRes = err 193 | } 194 | 195 | return errRes 196 | } 197 | 198 | // GetProxyURL 返回拼接了type后的URL 199 | func (cw *ClientConfigWidget) GetProxyUrl() string { 200 | if !cw.proxyBox.IsChecked() { 201 | return "" 202 | } 203 | 204 | pType := cw.proxyType.CurrentText() 205 | return pType + "://" + cw.proxy.Text() 206 | } 207 | 208 | // validProxy 验证proxy URL是否合法 209 | func (cw *ClientConfigWidget) validProxy() error { 210 | url := cw.GetProxyUrl() 211 | p := config.JSONProxy{Data: url} 212 | if !p.IsURL() && p.String() != "" { 213 | return config.ErrNotURL 214 | } 215 | 216 | return nil 217 | } 218 | 219 | // validLogFile 验证日志文件保存路径是否在$HOME下或者是绝对路径 220 | func (cw *ClientConfigWidget) validLogFile() error { 221 | text := cw.logFile.Text() 222 | return checkEmptyPath(text) 223 | } 224 | 225 | // validNodeConfigPath 验证ssr配置文件路径是否在$HOME下或者是绝对路径 226 | func (cw *ClientConfigWidget) validNodeConfigPath() error { 227 | text := cw.nodeConfigPath.Text() 228 | return checkPath(text) 229 | } 230 | 231 | // validBinPath 验证ssr可执行文件路径是否在$HOME下或者是绝对路径 232 | func (cw *ClientConfigWidget) validBinPath() error { 233 | text := cw.binPath.Text() 234 | return checkPath(text) 235 | } 236 | 237 | // validSSRConfigPath 验证ssr可执行文件路径是否在$HOME下或者是绝对路径 238 | func (cw *ClientConfigWidget) validSSRConfigPath() error { 239 | text := cw.ssrConfigPath.Text() 240 | return checkPath(text) 241 | } 242 | -------------------------------------------------------------------------------- /widgets/color_label.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/therecipe/qt/widgets" 7 | ) 8 | 9 | var ( 10 | // 控制颜色的qss模板 11 | colorStyle = "QLabel{color:%s;}" 12 | ) 13 | 14 | // ColorLabel 使用QSS显示彩色文字 15 | type ColorLabel struct { 16 | widgets.QLabel 17 | 18 | // color style sheet 19 | defaultColor string 20 | } 21 | 22 | // NewColorLabelWithColor 生成colorlabel,设置default color为color 23 | // color为空则设置为系统默认颜色 24 | // color可以是颜色对应的名字,例如"black", "green" 25 | // 也可以是16进制的RGB值,例如 #ffffff, #ff08ff, #000000 26 | func NewColorLabelWithColor(text, color string) *ColorLabel { 27 | l := NewColorLabel(nil, 0) 28 | 29 | l.SetDefaultColor(color) 30 | l.SetDefaultColorText(text) 31 | 32 | return l 33 | } 34 | 35 | // SetDefaultColor 设置defaultColor 36 | // color为""时设置为系统颜色 37 | // 不会改变现有text内容的颜色 38 | func (l *ColorLabel) SetDefaultColor(color string) { 39 | if color == "" { 40 | l.defaultColor = color 41 | return 42 | } 43 | 44 | l.defaultColor = fmt.Sprintf(colorStyle, color) 45 | } 46 | 47 | // ChangeColor 改变现有text的颜色 48 | // 并且设置defaultColor为新的颜色 49 | // color为""时设置为系统默认颜色 50 | func (l *ColorLabel) ChangeColor(color string) { 51 | l.SetDefaultColor(color) 52 | text := l.Text() 53 | l.SetDefaultColorText(text) 54 | } 55 | 56 | // SetColorText 用color显示新的text 57 | // color为""时显示系统默认颜色 58 | func (l *ColorLabel) SetColorText(text, color string) { 59 | var style string 60 | if color == "" { 61 | style = color 62 | } else { 63 | style = fmt.Sprintf(colorStyle, color) 64 | } 65 | 66 | l.SetText(text) 67 | l.SetStyleSheet(style) 68 | } 69 | 70 | // SetDefaultColorText 设置新的text值,并使其显示设置的default color 71 | func (l *ColorLabel) SetDefaultColorText(text string) { 72 | l.SetText(text) 73 | l.SetStyleSheet(l.defaultColor) 74 | } 75 | 76 | // DropColor 去除自定义颜色,显示系统主题默认的颜色 77 | func (l *ColorLabel) DropColor() { 78 | // 空字符串去除stylesheet 79 | l.SetStyleSheet("") 80 | l.defaultColor = "" 81 | } 82 | -------------------------------------------------------------------------------- /widgets/config_widget.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "github.com/therecipe/qt/widgets" 5 | 6 | "schannel-qt5/config" 7 | ) 8 | 9 | // ConfigWidget 显示和设置本客户端的配置 10 | type ConfigWidget struct { 11 | widgets.QWidget 12 | 13 | // 通知conf已经更新 14 | _ func(*config.UserConfig) `signal:"configChanged"` 15 | 16 | // client设置 17 | clientConfigWidget *ClientConfigWidget 18 | 19 | // ssr client设置 20 | ssrClientConfigWidget *SSRConfigWidget 21 | 22 | // 配置数据和接口 23 | conf *config.UserConfig 24 | 25 | // 变动的配置是否已经保存 26 | saved bool 27 | } 28 | 29 | // NewConfigWidget2 根据conf生成ConfigWidget 30 | func NewConfigWidget2(conf *config.UserConfig) *ConfigWidget { 31 | if conf == nil || conf.SSRClientConfig == nil { 32 | return nil 33 | } 34 | widget := NewConfigWidget(nil, 0) 35 | widget.conf = conf 36 | widget.saved = true 37 | widget.InitUI() 38 | 39 | return widget 40 | } 41 | 42 | // InitUI 初始化并显示 43 | func (w *ConfigWidget) InitUI() { 44 | w.clientConfigWidget = NewClientConfigWidget2(w.conf) 45 | w.clientConfigWidget.ConnectValueChanged(func() { 46 | w.setSaved(false) 47 | }) 48 | w.ssrClientConfigWidget = NewSSRConfigWidget2(w.conf.SSRClientConfig) 49 | w.ssrClientConfigWidget.ConnectValueChanged(func() { 50 | w.setSaved(false) 51 | }) 52 | 53 | saveButton := widgets.NewQPushButton2("保存", nil) 54 | saveButton.ConnectClicked(func(_ bool) { 55 | w.SaveConfig() 56 | }) 57 | 58 | // 大小策略,client和ssrClient大小2:1 59 | clientConfigSizePolicy := w.clientConfigWidget.SizePolicy() 60 | clientConfigSizePolicy.SetHorizontalPolicy(widgets.QSizePolicy__Expanding) 61 | clientConfigSizePolicy.SetHorizontalStretch(2) 62 | w.clientConfigWidget.SetSizePolicy(clientConfigSizePolicy) 63 | ssrSizePolicy := w.ssrClientConfigWidget.SizePolicy() 64 | ssrSizePolicy.SetHorizontalPolicy(widgets.QSizePolicy__Expanding) 65 | ssrSizePolicy.SetHorizontalStretch(1) 66 | w.ssrClientConfigWidget.SetSizePolicy(ssrSizePolicy) 67 | topLayout := widgets.NewQHBoxLayout() 68 | topLayout.AddWidget(w.clientConfigWidget, 0, 0) 69 | topLayout.AddWidget(w.ssrClientConfigWidget, 0, 0) 70 | mainLayout := widgets.NewQVBoxLayout() 71 | mainLayout.AddLayout(topLayout, 0) 72 | mainLayout.AddWidget(saveButton, 0, 0) 73 | w.SetLayout(mainLayout) 74 | } 75 | 76 | // saveConfig 验证并保存配置 77 | func (w *ConfigWidget) SaveConfig() { 78 | // 保存时不可修改设置信息 79 | w.SetEnabled(false) 80 | defer w.SetEnabled(true) 81 | 82 | var err error 83 | // 更新ssr client config 84 | err = w.ssrClientConfigWidget.UpdateSSRClientConfig() 85 | if err != nil { 86 | // 错误信息已经显示,无需使用showErrorDialog 87 | return 88 | } 89 | // 更新client config 90 | err = w.clientConfigWidget.UpdateClientConfig() 91 | if err != nil { 92 | return 93 | } 94 | 95 | if err := w.conf.StoreConfig(); err != nil { 96 | showErrorDialog("保存出错: " + err.Error(), w) 97 | return 98 | } 99 | 100 | // 设置状态为已保存 101 | w.setSaved(true) 102 | // 显示配置保存成功信息 103 | ShowNotification("配置保存", "配置保存成功", "", -1) 104 | // 通知其他组件配置发生变化 105 | w.ConfigChanged(w.conf) 106 | } 107 | 108 | // Saved 获取配置是否已经保存 109 | func (w *ConfigWidget) Saved() bool { 110 | return w.saved 111 | } 112 | 113 | // setSaved 设置配置是否已经保存 114 | func (w *ConfigWidget) setSaved(saved bool) { 115 | w.saved = saved 116 | } 117 | -------------------------------------------------------------------------------- /widgets/data_bridge.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "sync" 7 | "time" 8 | 9 | "github.com/therecipe/qt/core" 10 | 11 | "schannel-qt5/crawler" 12 | "schannel-qt5/parser" 13 | ) 14 | 15 | const ( 16 | // 日志子前缀 17 | dataBridgePrefix = "data_bridge: " 18 | ) 19 | 20 | // UserDataBridge 传递用户数据到界面组件 21 | type UserDataBridge interface { 22 | sync.Locker 23 | // ServiceInfos 获取服务信息 24 | ServiceInfos() []*parser.Service 25 | // SSRInfos 根据service信息获取ssr使用和节点信息 26 | SSRInfos(ser *parser.Service) *parser.SSRInfo 27 | // Invoices 获取账单信息 28 | Invoices() []*parser.Invoice 29 | // GetLogger 获取logger 30 | GetLogger() *log.Logger 31 | // GetCookies 获取用户的cookie 32 | GetCookies() []*http.Cookie 33 | // GetProxy 获取代理地址 34 | GetProxy() string 35 | } 36 | 37 | // accountDataProxy 用于获取和缓存用户数据的代理类 38 | type accountDataProxy struct { 39 | *sync.Mutex 40 | // 缓存过期时间,默认20min 41 | cached time.Time 42 | // 用户cookies,用于数据交互 43 | cookies []*http.Cookie 44 | // 记录日志 45 | logger *log.Logger 46 | // 代理URL 47 | proxy string 48 | // 用户数据 49 | ssrInfos []*parser.SSRInfo 50 | invoices []*parser.Invoice 51 | } 52 | 53 | // NewDataBridge 生成用户数据接口 54 | func NewDataBridge(cookies []*http.Cookie, proxy string, logger *log.Logger) UserDataBridge { 55 | u := &accountDataProxy{} 56 | u.Mutex = &sync.Mutex{} 57 | u.cookies = cookies 58 | u.proxy = proxy 59 | u.logger = logger 60 | 61 | return u 62 | } 63 | 64 | // checkCacheExpired 检查缓存是否过期,如果过期就更新 65 | // 虽然非并发安全,但是不公开,只能由公开接口调用,调用公开接口前加锁则不会发生数据竞争 66 | func (a *accountDataProxy) checkCacheExpired() { 67 | if time.Now().Sub(a.cached) < 20*time.Minute { 68 | return 69 | } 70 | 71 | servicesHTML, err := crawler.GetServiceHTML(a.cookies, a.proxy) 72 | if err != nil { 73 | a.logger.Printf(dataBridgePrefix+"%v\n", err) 74 | return 75 | } 76 | // 防止界面假死 77 | // 放在所有耗时的网络操作之后 78 | core.QCoreApplication_ProcessEvents(core.QEventLoop__ExcludeUserInputEvents) 79 | 80 | servicesList := parser.GetService(servicesHTML) 81 | tmp := make([]*parser.SSRInfo, 0, len(servicesList)) 82 | for _, ser := range servicesList { 83 | infoHTML, err := crawler.GetSSRInfoHTML(ser, a.cookies, a.proxy) 84 | if err != nil { 85 | a.logger.Printf(dataBridgePrefix+"%v\n", err) 86 | return 87 | } 88 | core.QCoreApplication_ProcessEvents(core.QEventLoop__ExcludeUserInputEvents) 89 | 90 | ssrInfo := parser.GetSSRInfo(infoHTML, ser) 91 | tmp = append(tmp, ssrInfo) 92 | } 93 | a.ssrInfos = tmp 94 | 95 | invoiceHTML, err := crawler.GetInvoiceHTML(a.cookies, a.proxy) 96 | core.QCoreApplication_ProcessEvents(core.QEventLoop__ExcludeUserInputEvents) 97 | if err != nil { 98 | a.logger.Printf(dataBridgePrefix+"%v\n", err) 99 | return 100 | } 101 | a.invoices = parser.GetInvoices(invoiceHTML) 102 | 103 | a.cached = time.Now() 104 | } 105 | 106 | // ServiceInfo 获取服务信息 107 | // 并发安全,因为只会修改slice而不会修改其中item的具体数据 108 | func (a *accountDataProxy) ServiceInfos() []*parser.Service { 109 | a.Lock() 110 | defer a.Unlock() 111 | a.checkCacheExpired() 112 | 113 | sers := make([]*parser.Service, 0, len(a.ssrInfos)) 114 | for i := range a.ssrInfos { 115 | sers = append(sers, a.ssrInfos[i].Service) 116 | } 117 | 118 | return sers 119 | } 120 | 121 | // SSRInfos 根据给出的Service返回ssr服务和节点信息 122 | // 并发安全,因为只有slice会被修改,其中的item不会被修改,因此没有数据竞争 123 | func (a *accountDataProxy) SSRInfos(ser *parser.Service) *parser.SSRInfo { 124 | a.Lock() 125 | defer a.Unlock() 126 | a.checkCacheExpired() 127 | 128 | for _, v := range a.ssrInfos { 129 | if *v.Service == *ser { 130 | return v 131 | } 132 | } 133 | 134 | return nil 135 | } 136 | 137 | // Invoices 返回所有账单信息 138 | // 并发安全 139 | func (a *accountDataProxy) Invoices() []*parser.Invoice { 140 | a.Lock() 141 | defer a.Unlock() 142 | a.checkCacheExpired() 143 | 144 | invoices := make([]*parser.Invoice, len(a.invoices)) 145 | copy(invoices, a.invoices) 146 | 147 | return invoices 148 | } 149 | 150 | // GetLogger 获取共享logger 151 | func (a *accountDataProxy) GetLogger() *log.Logger { 152 | a.Lock() 153 | defer a.Unlock() 154 | 155 | return a.logger 156 | } 157 | 158 | // GetCookies 获取登录后的用户身份cookies 159 | func (a *accountDataProxy) GetCookies() []*http.Cookie { 160 | a.Lock() 161 | defer a.Unlock() 162 | cookies := make([]*http.Cookie, len(a.cookies)) 163 | copy(cookies, a.cookies) 164 | 165 | return cookies 166 | } 167 | 168 | // GetProxy 获取代理地址 169 | func (a *accountDataProxy) GetProxy() string { 170 | a.Lock() 171 | defer a.Unlock() 172 | 173 | return a.proxy 174 | } 175 | -------------------------------------------------------------------------------- /widgets/downloader.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "os" 7 | "sync" 8 | 9 | "github.com/therecipe/qt/core" 10 | 11 | "schannel-qt5/crawler" 12 | ) 13 | 14 | // HTTPDownloader 通过HTTP下载文件 15 | // 配合goroutine和QProgressBar/QProgressDialog使用 16 | type HTTPDownloader struct { 17 | core.QObject 18 | 19 | // updateProgress 更新下载进度,size为已下载的byte数 20 | // updateMax 通知主线程下载文件的大小 21 | // failed 下载失败 22 | // done 下载完成 23 | _ func(size int) `signal:"updateProgress"` 24 | _ func(max int) `signal:"updateMax"` 25 | _ func(err error) `signal:"failed"` 26 | _ func() `signal:"done"` 27 | 28 | // stop停止下载并使Download返回 29 | _ func() `slot:"stop,auto"` 30 | 31 | // request 缓存下载请求 32 | // client 发起下载请求 33 | request *http.Request 34 | client *http.Client 35 | 36 | // 获取请求结果 37 | // resp保存请求结果 38 | responses chan *http.Response 39 | resp *http.Response 40 | 41 | // 控制isStopped标志,代表下载是否取消 42 | lock *sync.Mutex 43 | isStopped bool 44 | } 45 | 46 | // NewHTTPDownloader2 创建下载器 47 | // url为下载地址 48 | // file为保存的本地文件地址 49 | // referer为HTTP Header的Referer,可为空 50 | // proxy为代理,可设置为空 51 | // cookies用户身份凭证,可为空 52 | func NewHTTPDownloader2(url, referer, proxy string, 53 | cookies []*http.Cookie) (*HTTPDownloader, error) { 54 | downloader := NewHTTPDownloader(nil) 55 | downloader.lock = &sync.Mutex{} 56 | var err error 57 | downloader.client, err = crawler.GenClientWithProxy(proxy) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | downloader.request, err = http.NewRequest("GET", url, nil) 63 | if err != nil { 64 | return nil, err 65 | } 66 | crawler.SetRequestHeader(downloader.request, cookies, referer, "") 67 | 68 | downloader.responses = make(chan *http.Response, 1) 69 | go func() { 70 | defer close(downloader.responses) 71 | response, err := downloader.client.Do(downloader.request) 72 | if err != nil { 73 | downloader.Failed(err) 74 | return 75 | } 76 | 77 | downloader.responses <- response 78 | }() 79 | 80 | return downloader, nil 81 | } 82 | 83 | // TotalSize 下载文件的总大小 84 | func (d *HTTPDownloader) TotalSize() (int, error) { 85 | if d.resp == nil { 86 | var ok bool 87 | d.resp, ok = <-d.responses 88 | if !ok { 89 | return 0, errors.New("responses has been closed") 90 | } 91 | } 92 | 93 | return int(d.resp.ContentLength), nil 94 | } 95 | 96 | const ( 97 | chunk = 1024 * 200 // 一次下载的数据块大小(byte) 98 | ) 99 | 100 | // Download 下载文件,每下载一个chunk长度出发一次UpdateProgress信号 101 | // 下载被取消时删除已下载的部分文件 102 | func (d *HTTPDownloader) Download(file string) { 103 | totalSize, err := d.TotalSize() 104 | if err != nil { 105 | d.Failed(err) 106 | return 107 | } 108 | defer d.resp.Body.Close() 109 | d.UpdateMax(totalSize) 110 | 111 | f, err := os.OpenFile(file, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) 112 | if err != nil { 113 | d.Failed(err) 114 | return 115 | } 116 | defer f.Close() 117 | 118 | wrote := 0 119 | buf := make([]byte, chunk) 120 | for wrote < totalSize { 121 | d.lock.Lock() 122 | if d.isStopped { 123 | d.lock.Unlock() 124 | os.Remove(file) 125 | return 126 | } 127 | 128 | n, err := d.resp.Body.Read(buf) 129 | if err != nil { 130 | d.Failed(err) 131 | return 132 | } 133 | 134 | // golang的writer屏蔽了部分写,因此只需要检查err 135 | _, err = f.Write(buf[:n]) 136 | if err != nil { 137 | d.Failed(err) 138 | return 139 | } 140 | 141 | wrote += n 142 | d.UpdateProgress(wrote) 143 | d.lock.Unlock() 144 | } 145 | 146 | d.Done() 147 | } 148 | 149 | // stop 设置isStopped为true停止下载 150 | // fixme: 不会立刻停止,可能会延迟至下一次读写 151 | func (d *HTTPDownloader) stop() { 152 | d.lock.Lock() 153 | d.isStopped = true 154 | d.lock.Unlock() 155 | } 156 | -------------------------------------------------------------------------------- /widgets/invoice_dialog.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/therecipe/qt/core" 8 | "github.com/therecipe/qt/gui" 9 | "github.com/therecipe/qt/widgets" 10 | 11 | "schannel-qt5/crawler" 12 | "schannel-qt5/parser" 13 | ) 14 | 15 | // InvoiceDialog 显示全部的账单信息 16 | type InvoiceDialog struct { 17 | widgets.QDialog 18 | 19 | // goroutine中触发显示ErrorDialog及显示下载成功 20 | _ func(errInfo string) `signal:"errorHappened,auto"` 21 | _ func(file string) `signal:"downloadFinish,auto"` 22 | 23 | table *widgets.QTableWidget 24 | infoBar *widgets.QStatusBar 25 | // 是否在选中时复制到剪贴板 26 | copy2Clipboard *widgets.QCheckBox 27 | // 选中的行数 28 | selected *widgets.QLabel 29 | // 选中的链接 30 | link *widgets.QLabel 31 | 32 | invoices []*parser.Invoice 33 | dataBridge UserDataBridge 34 | } 35 | 36 | var ( 37 | // 表头 38 | cols = []string{ 39 | "账单编号", 40 | "链接", 41 | "开始日期", 42 | "结束日期", 43 | "金额(元)", 44 | "支付状态", 45 | } 46 | ) 47 | 48 | // NewInvoiceDialogWithData 生成dialog 49 | // bridge用户获取登录信息 50 | func NewInvoiceDialogWithData(bridge UserDataBridge, data []*parser.Invoice) *InvoiceDialog { 51 | dialog := NewInvoiceDialog(nil, 0) 52 | dialog.invoices = data 53 | dialog.dataBridge = bridge 54 | 55 | // 设置infobar,选中内容时显示账单链接 56 | dialog.infoBar = widgets.NewQStatusBar(nil) 57 | dialog.selected = widgets.NewQLabel2("未选中", nil, 0) 58 | dialog.link = widgets.NewQLabel(nil, 0) 59 | dialog.infoBar.AddPermanentWidget(dialog.selected, 0) 60 | dialog.infoBar.AddPermanentWidget(dialog.link, 0) 61 | 62 | dialog.copy2Clipboard = widgets.NewQCheckBox2("将链接复制到剪贴板", nil) 63 | dialog.copy2Clipboard.SetChecked(false) 64 | // 选中时如果已经选择了Link则进行复制 65 | dialog.copy2Clipboard.ConnectClicked(func(_ bool) { 66 | link := dialog.link.Text() 67 | if dialog.copy2Clipboard.IsChecked() && link != "" { 68 | dialog.copyLink(link) 69 | } 70 | }) 71 | 72 | // 初始化table,数据已经被排序 73 | dialog.table = widgets.NewQTableWidget(nil) 74 | // 设置行数,不设置将不显示任何数据 75 | dialog.table.SetRowCount(len(dialog.invoices)) 76 | // 设置表头 77 | dialog.table.SetColumnCount(len(cols)) 78 | dialog.table.SetHorizontalHeaderLabels(cols) 79 | // 去除边框 80 | dialog.table.SetShowGrid(false) 81 | dialog.table.SetFrameShape(widgets.QFrame__NoFrame) 82 | // 去除行号 83 | dialog.table.VerticalHeader().SetVisible(false) 84 | // 设置选择整行内容 85 | dialog.table.SetSelectionBehavior(widgets.QAbstractItemView__SelectRows) 86 | dialog.table.SetSelectionMode(widgets.QAbstractItemView__SingleSelection) 87 | // 设置table的数据项目 88 | dialog.setTable() 89 | 90 | dialog.table.ConnectCellClicked(func(row, col int) { 91 | dialog.setLink(dialog.invoices[row]) 92 | }) 93 | dialog.table.ConnectCellDoubleClicked(func(row int, column int) { 94 | dialog.showInvoiceView(dialog.invoices[row]) 95 | }) 96 | 97 | dialog.table.ConnectContextMenuEvent(dialog.invoiceContextMenu) 98 | 99 | // 设置不可编辑table 100 | dialog.table.SetEditTriggers(widgets.QAbstractItemView__NoEditTriggers) 101 | // Qt5.12.0无法正常折叠link text 102 | dialog.table.ResizeColumnsToContents() 103 | 104 | vbox := widgets.NewQVBoxLayout() 105 | vbox.AddWidget(dialog.table, 0, 0) 106 | vbox.AddWidget(dialog.copy2Clipboard, 0, core.Qt__AlignLeft) 107 | vbox.AddStretch(0) 108 | vbox.AddWidget(dialog.infoBar, 0, 0) 109 | dialog.SetLayout(vbox) 110 | dialog.setDialogSize() 111 | dialog.SetWindowTitle("账单详情") 112 | dialog.SetAttribute(core.Qt__WA_DeleteOnClose, true) 113 | 114 | return dialog 115 | } 116 | 117 | // setDialogSize 设置dialog宽度与table一致 118 | func (dialog *InvoiceDialog) setDialogSize() { 119 | width := 0 120 | for i := 0; i < len(cols); i++ { 121 | width += dialog.table.ColumnWidth(i) 122 | } 123 | dialog.SetMinimumWidth(width) 124 | } 125 | 126 | // setTable 设置table 127 | func (dialog *InvoiceDialog) setTable() { 128 | for row := 0; row < len(dialog.invoices); row++ { 129 | invoice := dialog.invoices[row] 130 | 131 | number := widgets.NewQTableWidgetItem2(invoice.Number, 0) 132 | dialog.table.SetItem(row, 0, number) 133 | link := widgets.NewQTableWidgetItem2(invoice.Link, 0) 134 | dialog.table.SetItem(row, 1, link) 135 | 136 | startTime := time2string(invoice.StartDate) 137 | start := widgets.NewQTableWidgetItem2(startTime, 0) 138 | dialog.table.SetItem(row, 2, start) 139 | expireTime := time2string(invoice.ExpireDate) 140 | expire := widgets.NewQTableWidgetItem2(expireTime, 0) 141 | dialog.table.SetItem(row, 3, expire) 142 | 143 | payment := strconv.FormatInt(invoice.Payment, 10) 144 | pay := widgets.NewQTableWidgetItem2(payment, 0) 145 | dialog.table.SetItem(row, 4, pay) 146 | 147 | text := "" 148 | color := "" 149 | if invoice.State == parser.NeedPay { 150 | text = "未付款" 151 | color = "red" 152 | } else if invoice.State == parser.FinishedPay { 153 | text = "已付款" 154 | color = "green" 155 | } 156 | label := NewColorLabelWithColor(text, color) 157 | dialog.table.SetCellWidget(row, 5, label) 158 | } 159 | } 160 | 161 | // setLink 当选中row中的单元格时将链接更新到infoBar 162 | func (dialog *InvoiceDialog) setLink(invoice *parser.Invoice) { 163 | index := 0 164 | for i, v := range dialog.invoices { 165 | if v == invoice { 166 | index = i 167 | break 168 | } 169 | } 170 | dialog.selected.SetText(fmt.Sprintf("选中第%d行", index+1)) 171 | dialog.link.SetText(invoice.Link) 172 | dialog.copyLink(invoice.Link) 173 | } 174 | 175 | // copyLink 如果勾选了copy2Clipboard则将link复制到系统剪贴板 176 | func (dialog *InvoiceDialog) copyLink(link string) { 177 | if dialog.copy2Clipboard.IsChecked() { 178 | dialog.copy(link) 179 | } 180 | } 181 | 182 | // copy 将值复制进剪贴板 183 | func (dialog *InvoiceDialog) copy(text string) { 184 | clip := gui.QGuiApplication_Clipboard() 185 | clip.SetText(text, gui.QClipboard__Clipboard) 186 | } 187 | 188 | // showInvoiceView 显示invoice对应的InvoiceViewWidget 189 | func (dialog *InvoiceDialog) showInvoiceView(invoice *parser.Invoice) { 190 | dialog.setLink(invoice) 191 | 192 | data, err := crawler.GetInvoiceInfoHTML(invoice, 193 | dialog.dataBridge.GetCookies(), 194 | dialog.dataBridge.GetProxy()) 195 | if err != nil { 196 | dialog.dataBridge.GetLogger().Println("GetInvoiceInfoHTML error:", err) 197 | return 198 | } 199 | viewHTML := parser.GetInvoiceViewHTML(string(data)) 200 | 201 | invoiceView := NewInvoiceViewWidget(dialog, 0) 202 | invoiceView.SetHTML(viewHTML) 203 | invoiceView.Show() 204 | } 205 | 206 | // invoiceContextMenu 显示table中invoice的右键菜单选项 207 | func (dialog *InvoiceDialog) invoiceContextMenu(_ *gui.QContextMenuEvent) { 208 | invoice := dialog.invoices[dialog.table.CurrentItem().Row()] 209 | dialog.setLink(invoice) 210 | 211 | menu := widgets.NewQMenu(dialog) 212 | menu.AddAction2(gui.NewQIcon5(":/image/ic_copy_link.svg"), "复制") 213 | menu.AddAction2(gui.NewQIcon5(":/image/download.svg"), "下载") 214 | menu.ConnectTriggered(func(action *widgets.QAction) { 215 | switch action.Text() { 216 | case "下载": 217 | dialog.download(invoice) 218 | case "复制": 219 | dialog.copy(invoice.Link) 220 | } 221 | }) 222 | 223 | menu.Exec2(gui.QCursor_Pos(), nil) 224 | menu.DestroyQMenu() 225 | } 226 | 227 | // errorHappened 收到goroutine返回的err并显示 228 | func (dialog *InvoiceDialog) errorHappened(errInfo string) { 229 | showErrorDialog(errInfo, dialog) 230 | } 231 | 232 | // 下载完成,显示成功信息 233 | func (dialog *InvoiceDialog) downloadFinish(file string) { 234 | info := fmt.Sprintf("%s下载成功", file) 235 | ShowNotification("账单", info, "", -1) 236 | } 237 | 238 | // download 下载选定的账单 239 | // 更新statusbar,启动另一个goroutine进行下载并反馈进度 240 | func (dialog *InvoiceDialog) download(invoice *parser.Invoice) { 241 | defaultName := fmt.Sprintf("账单-%s.pdf", invoice.Number) 242 | filter := "PDF Files(*.pdf)" 243 | // 获取上次保存文件的目录 244 | savePath, err := getFileSavePath("invoice", defaultName, filter, dialog) 245 | if err == ErrCanceled { 246 | return 247 | } else if err != nil { 248 | showErrorDialog("保存路径获取失败:"+err.Error(), dialog) 249 | return 250 | } 251 | 252 | cookies := dialog.dataBridge.GetCookies() 253 | proxy := dialog.dataBridge.GetProxy() 254 | html, err := crawler.GetInvoiceInfoHTML(invoice, cookies, proxy) 255 | if err != nil { 256 | logger := dialog.dataBridge.GetLogger() 257 | logger.Printf("GetInvoiceInfoHTML error: %v\n", err) 258 | dialog.ErrorHappened("获取下载地址失败:" + err.Error()) 259 | return 260 | } 261 | downloadURL := parser.GetInvoiceDownloadURL(html) 262 | downloader, err := NewHTTPDownloader2(downloadURL, invoice.Link, proxy, cookies) 263 | if err != nil { 264 | logger := dialog.dataBridge.GetLogger() 265 | logger.Printf("NewHTTPDownloader2 error: %v\n", err) 266 | showErrorDialog("获取下载器失败:"+err.Error(), dialog) 267 | return 268 | } 269 | downloader.SetParent(dialog) 270 | 271 | progressDialog := getProgressDialog("保存账单", "账单下载进度:", dialog) 272 | progressDialog.ConnectCanceled(func() { 273 | downloader.Stop() 274 | progressDialog.Cancel() 275 | dialog.ErrorHappened("下载已取消") 276 | }) 277 | downloader.ConnectUpdateProgress(func(size int) { 278 | // 已经cancel的dialog不能调用setValue,避免dialog反复出现 279 | if progressDialog.WasCanceled() { 280 | return 281 | } 282 | 283 | progressDialog.SetValue(size) 284 | }) 285 | downloader.ConnectUpdateMax(progressDialog.SetMaximum) 286 | downloader.ConnectFailed(func(err error) { 287 | dialog.ErrorHappened("下载发生错误:" + err.Error()) 288 | }) 289 | downloader.ConnectDone(func() { 290 | progressDialog.Cancel() 291 | dialog.DownloadFinish(savePath) 292 | }) 293 | 294 | go downloader.Download(savePath) 295 | progressDialog.Exec() 296 | } 297 | -------------------------------------------------------------------------------- /widgets/invoice_panel.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/therecipe/qt/widgets" 7 | 8 | "schannel-qt5/parser" 9 | ) 10 | 11 | // InvoicePanel 显示账单状态 12 | type InvoicePanel struct { 13 | widgets.QWidget 14 | 15 | status *ColorLabel 16 | showInvoices *widgets.QPushButton 17 | 18 | dataBridge UserDataBridge 19 | // 缓存的账单信息 20 | invoices []*parser.Invoice 21 | } 22 | 23 | // sortInvoices 将账单按开始日期倒序排序 24 | func (panel *InvoicePanel) sortInvoices() { 25 | sort.Slice(panel.invoices, func(i, j int) bool { 26 | if panel.invoices[i].StartDate.Before(panel.invoices[j].StartDate) { 27 | return false 28 | } 29 | 30 | return true 31 | }) 32 | } 33 | 34 | // NewInvoicePanelWithData 生成InvoicePanel 35 | func NewInvoicePanelWithData(bridge UserDataBridge) *InvoicePanel { 36 | panel := NewInvoicePanel(nil, 0) 37 | panel.dataBridge = bridge 38 | invoices := panel.dataBridge.Invoices() 39 | panel.invoices = make([]*parser.Invoice, len(invoices)) 40 | copy(panel.invoices, invoices) 41 | panel.sortInvoices() 42 | 43 | group := widgets.NewQGroupBox2("账单情况", nil) 44 | hbox := widgets.NewQHBoxLayout() 45 | 46 | panel.setInvoiceStatus() 47 | hbox.AddWidget(panel.status, 0, 0) 48 | 49 | panel.showInvoices = widgets.NewQPushButton2("详细账单", nil) 50 | panel.showInvoices.ConnectClicked(panel.showInvoiceDialog) 51 | hbox.AddWidget(panel.showInvoices, 0, 0) 52 | 53 | group.SetLayout(hbox) 54 | mainLayout := widgets.NewQHBoxLayout() 55 | mainLayout.AddWidget(group, 0, 0) 56 | panel.SetLayout(mainLayout) 57 | 58 | return panel 59 | } 60 | 61 | // showInvoiceDialog 显示详细信息对话框 62 | func (panel *InvoicePanel) showInvoiceDialog(_ bool) { 63 | dialog := NewInvoiceDialogWithData(panel.dataBridge, panel.invoices) 64 | // 显示遮罩 65 | shade := NewShadeWidget2(panel.NativeParentWidget()) 66 | dialog.Exec() 67 | shade.Close() 68 | } 69 | 70 | // setInvoiceStatus 设置invoice的显示信息和颜色 71 | func (panel *InvoicePanel) setInvoiceStatus() { 72 | text, isPaid := panel.invoices[0].GetStatus() 73 | if isPaid { 74 | panel.status = NewColorLabelWithColor(text, "green") 75 | } else { 76 | panel.status = NewColorLabelWithColor(text, "red") 77 | } 78 | } 79 | 80 | // UpdateInvoices 刷新账单信息显示 81 | func (panel *InvoicePanel) UpdateInvoices(data []*parser.Invoice) { 82 | panel.invoices = make([]*parser.Invoice, len(data)) 83 | copy(panel.invoices, data) 84 | panel.sortInvoices() 85 | panel.setInvoiceStatus() 86 | } 87 | -------------------------------------------------------------------------------- /widgets/invoice_view_widget.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "github.com/therecipe/qt/core" 5 | "github.com/therecipe/qt/gui" 6 | "github.com/therecipe/qt/webengine" 7 | "github.com/therecipe/qt/widgets" 8 | ) 9 | 10 | // InvoiceViewWidget 使用QWebEngineView展示账单详情 11 | type InvoiceViewWidget struct { 12 | widgets.QMainWindow 13 | 14 | _ func() `constructor:"init"` 15 | 16 | // 移动前的位置,用于计算需要位移的距离 17 | lastPosition *core.QPoint 18 | mousePressed bool 19 | 20 | webEngine *webengine.QWebEngineView 21 | // 接受QWebEngineView事件的子对象 22 | webEngineChild *core.QObject 23 | 24 | // 窗口移动是否有效 25 | moveAble bool 26 | } 27 | 28 | func (i *InvoiceViewWidget) init() { 29 | i.SetAttribute(core.Qt__WA_DeleteOnClose, true) 30 | i.SetWindowFlag(core.Qt__FramelessWindowHint, true) 31 | 32 | i.webEngine = webengine.NewQWebEngineView(nil) 33 | i.SetCentralWidget(i.webEngine) 34 | 35 | closeButton := NewTransparentButtonWithStyle("{background:url(:/image/close.png);}") 36 | closeButton.Resize2(36, 36) 37 | closeButton.SetParent(i) 38 | closeButton.Move2(0, 0) 39 | closeButton.ConnectClicked(func(_ bool) { 40 | i.Close() 41 | }) 42 | closeButton.SetToolTip("关闭窗口") 43 | // fix window position 44 | fixButton := NewTransparentButtonWithStyle("{background-color:blue;background-image:url(:/image/Removefixed.svg);}") 45 | fixButton.Resize2(36, 36) 46 | fixButton.SetParent(i) 47 | fixButton.Move2(0, 36) 48 | fixButton.ConnectClicked(func(_ bool) { 49 | i.moveAble = !i.moveAble 50 | }) 51 | fixButton.SetToolTip("禁止/允许拖动窗口") 52 | 53 | // window move event 54 | i.webEngine.ConnectMousePressEvent(func(event *gui.QMouseEvent) { 55 | if event.Button() == core.Qt__LeftButton { 56 | i.lastPosition = event.GlobalPos() 57 | i.mousePressed = true 58 | return 59 | } 60 | 61 | i.webEngine.MousePressEventDefault(event) 62 | }) 63 | 64 | i.webEngine.ConnectMouseReleaseEvent(func(event *gui.QMouseEvent) { 65 | i.mousePressed = false 66 | i.webEngine.MouseReleaseEventDefault(event) 67 | }) 68 | 69 | i.webEngine.ConnectMouseMoveEvent(func(event *gui.QMouseEvent) { 70 | if i.mousePressed { 71 | movementX := event.GlobalX() - i.lastPosition.X() 72 | movementY := event.GlobalY() - i.lastPosition.Y() 73 | i.lastPosition = event.GlobalPos() 74 | i.Move2(i.X()+movementX, i.Y()+movementY) 75 | return 76 | } 77 | 78 | i.webEngine.MouseMoveEventDefault(event) 79 | }) 80 | 81 | // 获取WebEngineView子对象 82 | i.webEngine.ConnectEvent(func(event *core.QEvent) bool { 83 | if event.Type() == core.QEvent__ChildPolished { 84 | childEvent := core.NewQChildEventFromPointer(event.Pointer()) 85 | if childEvent.Child() != nil { 86 | i.webEngineChild = childEvent.Child() 87 | i.webEngineChild.InstallEventFilter(i.webEngine) 88 | } 89 | } 90 | 91 | return i.webEngine.EventDefault(event) 92 | }) 93 | 94 | // 处理鼠标事件 95 | i.webEngine.ConnectEventFilter(func(watched *core.QObject, event *core.QEvent) bool { 96 | if watched.Pointer() == i.webEngineChild.Pointer() { 97 | // 窗口移动被禁止 98 | if !i.moveAble { 99 | return false 100 | } 101 | 102 | switch event.Type() { 103 | case core.QEvent__MouseButtonPress: 104 | mouseEvent := gui.NewQMouseEventFromPointer(event.Pointer()) 105 | if mouseEvent.Button() == core.Qt__LeftButton { 106 | i.webEngine.MousePressEvent(mouseEvent) 107 | return true 108 | } 109 | return false 110 | case core.QEvent__MouseButtonRelease: 111 | mouseEvent := gui.NewQMouseEventFromPointer(event.Pointer()) 112 | if mouseEvent.Button() == core.Qt__LeftButton { 113 | i.webEngine.MouseReleaseEvent(mouseEvent) 114 | return true 115 | } 116 | return false 117 | case core.QEvent__MouseMove: 118 | mouseEvent := gui.NewQMouseEventFromPointer(event.Pointer()) 119 | i.webEngine.MouseMoveEvent(mouseEvent) 120 | return true 121 | } 122 | } 123 | 124 | return i.webEngine.EventFilterDefault(watched, event) 125 | }) 126 | 127 | i.webEngine.ConnectContextMenuEvent(func(_ *gui.QContextMenuEvent) { 128 | // 去除自带的右键菜单 129 | }) 130 | 131 | // 设置合适的宽度完整显示invoice表格内容 132 | i.Resize2(800, 650) 133 | } 134 | 135 | // 设置view展示的内容 136 | func (i *InvoiceViewWidget) SetHTML(html string) { 137 | i.webEngine.SetHtml(html, core.NewQUrl()) 138 | } 139 | -------------------------------------------------------------------------------- /widgets/login_indicator.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import "github.com/therecipe/qt/widgets" 4 | 5 | // login时显示正在登录的busy indicator 6 | type LoginIndicator struct { 7 | widgets.QWidget 8 | 9 | infoLabel *widgets.QLabel 10 | progress *widgets.QProgressBar 11 | } 12 | 13 | func NewLoginIndicator2() *LoginIndicator { 14 | indicator := NewLoginIndicator(nil, 0) 15 | indicator.InitUI() 16 | 17 | return indicator 18 | } 19 | 20 | func (i *LoginIndicator) InitUI() { 21 | i.infoLabel = widgets.NewQLabel2("登录中:", nil, 0) 22 | i.progress = widgets.NewQProgressBar(nil) 23 | i.progress.SetRange(0, 0) 24 | 25 | mainLayout := widgets.NewQHBoxLayout() 26 | mainLayout.AddWidget(i.infoLabel, 0, 0) 27 | mainLayout.AddWidget(i.progress, 0, 0) 28 | i.SetLayout(mainLayout) 29 | } 30 | -------------------------------------------------------------------------------- /widgets/login_widget.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/astaxie/beego/orm" 9 | "github.com/therecipe/qt/core" 10 | "github.com/therecipe/qt/widgets" 11 | 12 | "schannel-qt5/config" 13 | "schannel-qt5/crawler" 14 | "schannel-qt5/models" 15 | ) 16 | 17 | // LoginWidget 登录界面 18 | type LoginWidget struct { 19 | widgets.QWidget 20 | 21 | // loginFailed 登录失败显示错误信息 22 | // loginSuccess 将登录成功的用户名和cookies传递给父控件 23 | _ func(string) `signal:"loginFailed,auto"` 24 | _ func(string, []*http.Cookie) `signal:"loginSuccess"` 25 | 26 | username *widgets.QComboBox 27 | password *widgets.QLineEdit 28 | loginStatus *ColorLabel 29 | showPassword *widgets.QCheckBox 30 | remember *widgets.QCheckBox 31 | loginButton *widgets.QPushButton 32 | indicator *LoginIndicator 33 | 34 | // 用户数据 35 | conf *config.UserConfig 36 | logger *log.Logger 37 | db orm.Ormer 38 | } 39 | 40 | // NewLoginWidget2 根据config,logger,db生成登录控件 41 | func NewLoginWidget2(conf *config.UserConfig, logger *log.Logger, db orm.Ormer) *LoginWidget { 42 | if conf == nil || logger == nil { 43 | return nil 44 | } 45 | 46 | widget := NewLoginWidget(nil, 0) 47 | widget.conf = conf 48 | widget.logger = logger 49 | widget.db = db 50 | widget.InitUI() 51 | 52 | return widget 53 | } 54 | 55 | func (l *LoginWidget) InitUI() { 56 | l.username = widgets.NewQComboBox(nil) 57 | l.username.SetEditable(true) 58 | l.username.LineEdit().SetClearButtonEnabled(true) 59 | users, err := models.GetAllUsers(l.db) 60 | if err != nil { 61 | l.logger.Fatalln(err) 62 | } 63 | 64 | names := make([]string, 0, len(users)) 65 | for _, v := range users { 66 | names = append(names, v.Name) 67 | } 68 | userList := widgets.NewQListWidget(nil) 69 | // 设置combobox代理 70 | l.username.SetModel(userList.Model()) 71 | l.username.SetView(userList) 72 | 73 | maxViewWidth := 0 74 | for _, name := range names { 75 | accountItem := NewAccountItem2(name) 76 | // 设置下拉框宽度与宽度最大item一致 77 | if accountItem.SizeHint().Width() > maxViewWidth { 78 | maxViewWidth = accountItem.SizeHint().Width() 79 | l.username.View().SetFixedWidth(maxViewWidth) 80 | } 81 | // ComboBox处理item被选中和点击删除按钮 82 | accountItem.ConnectShowAccount(func(userName string) { 83 | l.username.HidePopup() 84 | for i, v := range names { 85 | if v == userName { 86 | l.username.SetCurrentIndex(i) 87 | l.username.SetCurrentText(userName) 88 | break 89 | } 90 | } 91 | }) 92 | 93 | accountItem.ConnectRemoveAccount(func(userName string) { 94 | l.username.HidePopup() 95 | 96 | info := fmt.Sprintf("将删除用户:%s (同时删除使用数据)", userName) 97 | buttons := widgets.QMessageBox__Yes | widgets.QMessageBox__Cancel 98 | defaultButton := widgets.QMessageBox__Yes 99 | shade := NewShadeWidget2(l.NativeParentWidget()) 100 | answer := widgets.QMessageBox_Question4(l, "是否删除记录", info, buttons, defaultButton) 101 | shade.Close() 102 | if answer != int(widgets.QMessageBox__Yes) { 103 | return 104 | } 105 | 106 | // listWidget中的顺序和names一致 107 | for i, v := range names { 108 | if userName == v { 109 | userList.TakeItem(i) 110 | break 111 | } 112 | } 113 | 114 | err := models.DelUser(l.db, userName) 115 | if err != nil { 116 | l.logger.Fatal("删除用户失败:", userName, err) 117 | } 118 | }) 119 | 120 | listItem := widgets.NewQListWidgetItem(userList, 0) 121 | userList.SetItemWidget(listItem, accountItem) 122 | } 123 | 124 | l.password = widgets.NewQLineEdit(nil) 125 | l.password.SetPlaceholderText("密码") 126 | l.password.SetEchoMode(widgets.QLineEdit__Password) 127 | 128 | // 勾选是否明文显示密码 129 | l.showPassword = widgets.NewQCheckBox2("显示密码", nil) 130 | l.showPassword.SetChecked(false) 131 | l.showPassword.ConnectClicked(func(_ bool) { 132 | if l.showPassword.IsChecked() { 133 | l.password.SetEchoMode(widgets.QLineEdit__Normal) 134 | return 135 | } 136 | 137 | l.password.SetEchoMode(widgets.QLineEdit__Password) 138 | }) 139 | 140 | l.remember = widgets.NewQCheckBox2("记住用户名和密码", nil) 141 | // 设置第一个记录用户的密码 142 | // 因为comboBox默认选择显示第一个name,不会触发信号 143 | if len(users) != 0 { 144 | info, err := models.GetUserPassword(l.db, names[0]) 145 | if err != nil { 146 | l.logger.Println(err) 147 | } else { 148 | if info.Passwd != "" { 149 | // 密码不为空,设置密码和选中记住密码 150 | l.password.SetText(string(info.Passwd)) 151 | l.username.SetCurrentText(info.Name) 152 | l.remember.SetChecked(true) 153 | } 154 | } 155 | } 156 | // 实现记住用户密码 157 | l.username.ConnectCurrentTextChanged(l.setPassword) 158 | 159 | // 空的ColorLabel,预备填充错误信息 160 | l.loginStatus = NewColorLabelWithColor("", "red") 161 | l.loginStatus.Hide() 162 | 163 | // login时显示busy进度条 164 | l.indicator = NewLoginIndicator2() 165 | l.indicator.Hide() 166 | 167 | l.loginButton = widgets.NewQPushButton2("登录", nil) 168 | l.loginButton.ConnectClicked(l.login) 169 | 170 | loginLayout := widgets.NewQHBoxLayout() 171 | loginLayout.AddWidget(l.remember, 0, 0) 172 | loginLayout.AddStretch(0) 173 | loginLayout.AddWidget(l.loginButton, 0, 0) 174 | 175 | mainLayout := widgets.NewQFormLayout(nil) 176 | mainLayout.AddRow5(l.loginStatus) 177 | mainLayout.AddRow3("用户名:", l.username) 178 | mainLayout.AddRow3("密码:", l.password) 179 | mainLayout.AddRow5(l.showPassword) 180 | mainLayout.AddRow6(loginLayout) 181 | mainLayout.AddRow5(l.indicator) 182 | l.SetLayout(mainLayout) 183 | sizePolicy := l.SizePolicy() 184 | sizePolicy.SetHorizontalPolicy(widgets.QSizePolicy__Minimum) 185 | l.SetSizePolicy(sizePolicy) 186 | l.ConnectSizeHint(func() *core.QSize { 187 | return core.NewQSize2(360, 180) 188 | }) 189 | } 190 | 191 | func (l *LoginWidget) login(_ bool) { 192 | l.indicator.Show() 193 | l.setEditAreaEnabled(false) 194 | 195 | go l.checkLogin() 196 | } 197 | 198 | // 控制输入区是否可编辑,禁止用户在登录过程中影响输入信息 199 | func (l *LoginWidget) setEditAreaEnabled(enabled bool) { 200 | l.username.SetEnabled(enabled) 201 | l.password.SetEnabled(enabled) 202 | l.showPassword.SetEnabled(enabled) 203 | l.remember.SetEnabled(enabled) 204 | l.loginButton.SetEnabled(enabled) 205 | } 206 | 207 | // checkLogin 请求登录,用户名密码正确则登陆成功 208 | // 勾选了remember时将会更新记录进数据库 209 | // 登录失败显示失败信息 210 | func (l *LoginWidget) checkLogin() { 211 | passwd := l.password.Text() 212 | user := l.username.CurrentText() 213 | if user == "" || passwd == "" { 214 | l.LoginFailed("用户名/密码不能为空") 215 | return 216 | } 217 | 218 | // 登录 219 | cookies, err := crawler.GetAuth(user, passwd, l.conf.Proxy.String()) 220 | if err != nil { 221 | l.logger.Printf("crawler failed: %v\n", err) 222 | l.LoginFailed("用户名或密码错误") 223 | return 224 | } 225 | 226 | // 登陆成功,记住密码 227 | if l.remember.IsChecked() { 228 | if err := models.SetUserPassword(l.db, user, passwd); err != nil { 229 | l.logger.Println(err) 230 | } 231 | } else { 232 | // 如果未勾选,表示用户不想记住密码,已经记住的将会被设置为null 233 | cond := orm.NewCondition() 234 | cond = cond.And("Passwd__isnull", false).And("Name", user) 235 | if l.db.QueryTable(&models.User{}).SetCond(cond).Exist() { 236 | if err := models.DelPassword(l.db, user); err != nil { 237 | l.logger.Printf("delete %v password failed: %v\n", user, err) 238 | } 239 | } 240 | } 241 | 242 | // 传递登录信息 243 | l.logger.Printf("logined as [%s] success\n", user) 244 | ShowNotification("登录", user+"登陆成功", "", -1) 245 | l.LoginSuccess(user, cookies) 246 | } 247 | 248 | // 更新并显示错误信息 249 | func (l *LoginWidget) loginFailed(errInfo string) { 250 | l.indicator.Hide() 251 | l.setEditAreaEnabled(true) 252 | 253 | l.loginStatus.SetDefaultColorText(errInfo) 254 | if l.loginStatus.IsHidden() { 255 | l.loginStatus.Show() 256 | } 257 | } 258 | 259 | // setPassword 将密码不为null的用户显示 260 | func (l *LoginWidget) setPassword(user string) { 261 | info, err := models.GetUserPassword(l.db, user) 262 | if err != nil { 263 | // 输入的用户名未记录 264 | l.logger.Println(err) 265 | } else if info.Passwd != "" { 266 | l.password.SetText(string(info.Passwd)) 267 | l.remember.SetChecked(true) 268 | return 269 | } 270 | 271 | // 记录了用户但是没记录密码 272 | l.password.SetText("") 273 | l.remember.SetChecked(false) 274 | } 275 | -------------------------------------------------------------------------------- /widgets/main_widget.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/astaxie/beego/orm" 9 | "github.com/therecipe/qt/gui" 10 | "github.com/therecipe/qt/widgets" 11 | 12 | "schannel-qt5/config" 13 | ) 14 | 15 | // MainWidget 客户端主界面,并处理子widget信号 16 | type MainWidget struct { 17 | widgets.QMainWindow 18 | 19 | login *LoginWidget 20 | // 可能具有多个服务 21 | summary []*SummarizedWidget 22 | // 客户端配置widget 23 | setting *ConfigWidget 24 | // 包含三种widget的tabWidget 25 | tab *widgets.QTabWidget 26 | 27 | // 用户数据接口 28 | dataBridge UserDataBridge 29 | // 用户配置 30 | conf *config.UserConfig 31 | // 存储用户登录信息和使用信息 32 | db orm.Ormer 33 | // 用户名 34 | user string 35 | // 日志记录 36 | logger *log.Logger 37 | } 38 | 39 | // NewMainWidget2 创建主界面 40 | func NewMainWidget2(conf *config.UserConfig, logger *log.Logger, db orm.Ormer) *MainWidget { 41 | widget := NewMainWidget(nil, 0) 42 | widget.conf = conf 43 | widget.db = db 44 | widget.logger = logger 45 | widget.InitUI() 46 | 47 | return widget 48 | } 49 | 50 | // InitUI 初始化UI 51 | // 先初始化login,登录成功后login隐藏,再初始化summary和setting 52 | func (m *MainWidget) InitUI() { 53 | m.tab = widgets.NewQTabWidget(nil) 54 | m.login = NewLoginWidget2(m.conf, m.logger, m.db) 55 | m.tab.AddTab(m.login, "登录") 56 | m.login.ConnectLoginSuccess(m.finishLogin) 57 | m.SetWindowTitle("schannel-qt5") 58 | m.SetCentralWidget(m.tab) 59 | m.SetWindowIcon(gui.NewQIcon5(":/image/icon.svg")) 60 | } 61 | 62 | // finishLogin 登录成功后隐藏LoginWidget,显示summary和setting 63 | func (m *MainWidget) finishLogin(user string, cookies []*http.Cookie) { 64 | m.user = user 65 | m.dataBridge = NewDataBridge(cookies, m.conf.Proxy.String(), m.logger) 66 | // 删除login,因为目前只有login一个widget所以index是0 67 | m.tab.RemoveTab(0) 68 | 69 | m.setting = NewConfigWidget2(m.conf) 70 | // 关闭时确认配置修改的保存 71 | m.ConnectCloseEvent(func(event *gui.QCloseEvent) { 72 | if m.setting.Saved() { 73 | event.Accept() 74 | return 75 | } 76 | 77 | info := "配置信息尚未保存,是否保存?" 78 | buttons := widgets.QMessageBox__Yes | widgets.QMessageBox__Cancel | widgets.QMessageBox__No 79 | defaultButton := widgets.QMessageBox__Cancel 80 | shade := NewShadeWidget2(m.QWidget_PTR()) 81 | answer := widgets.QMessageBox_Question4(m, "配置未保存", info, buttons, defaultButton) 82 | shade.Close() 83 | switch answer { 84 | case int(widgets.QMessageBox__No): 85 | event.Accept() 86 | case int(widgets.QMessageBox__Cancel): 87 | event.Ignore() 88 | case int(widgets.QMessageBox__Yes): 89 | m.setting.SaveConfig() 90 | // 保存失败停止关闭 91 | if m.setting.Saved() { 92 | event.Accept() 93 | } else { 94 | // 保存不成功,转至settings 95 | m.tab.SetCurrentWidget(m.setting) 96 | event.Ignore() 97 | } 98 | default: 99 | event.Ignore() 100 | } 101 | }) 102 | 103 | // 可能存在多个服务 104 | services := m.dataBridge.ServiceInfos() 105 | for i, service := range services { 106 | widget := NewSummarizedWidget2(i, m.user, service, m.conf, m.dataBridge) 107 | // 处理更新请求 108 | widget.ConnectServiceNeedUpdate(func(index int) { 109 | services := m.dataBridge.ServiceInfos() 110 | widget.SetService(services[index]) 111 | widget.DataRefresh() 112 | m.logger.Printf("服务%d 数据已更新\n", index+1) 113 | }) 114 | // 处理配置更新 115 | m.setting.ConnectConfigChanged(widget.UpdateConfig) 116 | 117 | serviceTabName := fmt.Sprintf("服务%d:%s", i+1, service.Name) 118 | m.tab.AddTab(widget, serviceTabName) 119 | m.summary = append(m.summary, widget) 120 | m.logger.Printf("已添加综合信息面板:服务%d\n", i+1) 121 | } 122 | m.tab.AddTab(m.setting, "设置") 123 | // 移动到左上角,避免窗口因较长显示不完整 124 | m.Move2(0, 0) 125 | } 126 | -------------------------------------------------------------------------------- /widgets/node_model.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "github.com/therecipe/qt/core" 8 | "github.com/therecipe/qt/gui" 9 | 10 | "schannel-qt5/geoip" 11 | "schannel-qt5/parser" 12 | ) 13 | 14 | var CountryFlags = make([]map[string]string, 0) 15 | 16 | func init() { 17 | // 获取countryCode对应的flag emoji 18 | flagData := core.NewQFile2(":/flags/data.json") 19 | flagData.Open(core.QIODevice__ReadOnly) 20 | err := json.Unmarshal([]byte(flagData.ReadAll().Data()), &CountryFlags) 21 | if err != nil { 22 | CountryFlags = nil 23 | } 24 | flagData.Close() 25 | } 26 | 27 | // NodeTreeItem 将节点保存成地区/名字的树形结构 28 | type NodeTreeItem struct { 29 | core.QObject 30 | // 节点或地区的名字 31 | name string 32 | node *parser.SSRNode 33 | 34 | parent *NodeTreeItem 35 | children []*NodeTreeItem 36 | } 37 | 38 | // NewNodeTreeItem2 用name创建NodeTreeItem 39 | func NewNodeTreeItem2(name string) *NodeTreeItem { 40 | item := NewNodeTreeItem(nil) 41 | item.name = name 42 | item.children = make([]*NodeTreeItem, 0) 43 | // 清理资源,goqt无法自动清理 44 | item.ConnectDestroyNodeTreeItem(item.destroyTreeItem) 45 | 46 | return item 47 | } 48 | 49 | // 释放所有children 50 | func (n *NodeTreeItem) destroyTreeItem() { 51 | for _, child := range n.children { 52 | child.DestroyNodeTreeItem() 53 | } 54 | 55 | n.DestroyNodeTreeItemDefault() 56 | } 57 | 58 | func (n *NodeTreeItem) ChildCount() int { 59 | return len(n.children) 60 | } 61 | 62 | func (n *NodeTreeItem) ColumnCount() int { 63 | return 1 64 | } 65 | 66 | // 返回自己的直接子节点 67 | func (n *NodeTreeItem) Child(row int) *NodeTreeItem { 68 | return n.children[row] 69 | } 70 | 71 | func (n *NodeTreeItem) ParentItem() *NodeTreeItem { 72 | return n.parent 73 | } 74 | 75 | // 添加节点至当前节点的children 76 | func (n *NodeTreeItem) AppendChild(child *NodeTreeItem) { 77 | child.parent = n 78 | n.children = append(n.children, child) 79 | } 80 | 81 | func (n *NodeTreeItem) Data() string { 82 | return n.name 83 | } 84 | 85 | // Row 返回当前节点在父节点的children中的索引 86 | func (n *NodeTreeItem) Row() int { 87 | if n.parent == nil || len(n.parent.children) < 1 { 88 | return 0 89 | } 90 | 91 | for i, child := range n.parent.children { 92 | if child == n { 93 | return i 94 | } 95 | } 96 | 97 | return 0 98 | } 99 | 100 | // 根据name查找当前tree的子节点(不进行递归查找) 101 | func (n *NodeTreeItem) FindChild(name string) *NodeTreeItem { 102 | for _, item := range n.children { 103 | if item.name == name { 104 | return item 105 | } 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func (n *NodeTreeItem) SetNode(node *parser.SSRNode) { 112 | n.node = node 113 | } 114 | 115 | func (n *NodeTreeItem) Node() *parser.SSRNode { 116 | return n.node 117 | } 118 | 119 | // LatestChild 返回末端节点 120 | // 如果已经是末端节点则返回自己 121 | func (n *NodeTreeItem) LatestChild(row int) *NodeTreeItem { 122 | if len(n.children) == 0 { 123 | return n 124 | } 125 | 126 | child := n.Child(row) 127 | for len(child.children) != 0 { 128 | child = child.Child(row) 129 | } 130 | 131 | return child 132 | } 133 | 134 | // NodeTreeModel 保存按地理信息分类的nodes 135 | type NodeTreeModel struct { 136 | core.QAbstractItemModel 137 | 138 | // 根节点 139 | rootItem *NodeTreeItem 140 | } 141 | 142 | // NewNodeTreeModel2 用nodes初始化model 143 | func NewNodeTreeModel2(nodes []*parser.SSRNode) *NodeTreeModel { 144 | model := NewNodeTreeModel(nil) 145 | model.rootItem = NewNodeTreeItem2("") 146 | for _, node := range nodes { 147 | // 通过geo信息逐层查找,找到或新建对应的最底层地理区域节点 148 | geo := strings.Split(getGeoName(node.IP), "-") 149 | baseItem := model.rootItem 150 | for _, area := range geo { 151 | areaItem := baseItem.FindChild(area) 152 | if areaItem == nil { 153 | areaItem = NewNodeTreeItem2(area) 154 | baseItem.AppendChild(areaItem) 155 | } 156 | 157 | baseItem = areaItem 158 | } 159 | 160 | nodeItem := NewNodeTreeItem2(node.NameNumber()) 161 | nodeItem.SetNode(node) 162 | baseItem.AppendChild(nodeItem) 163 | } 164 | 165 | model.ConnectIndex(model.index) 166 | model.ConnectParent(model.parent) 167 | model.ConnectHeaderData(model.headerData) 168 | model.ConnectRowCount(model.rowCount) 169 | model.ConnectColumnCount(model.columnCount) 170 | model.ConnectData(model.data) 171 | 172 | return model 173 | } 174 | 175 | // 返回子节点的index 176 | func (n *NodeTreeModel) index(row int, column int, parent *core.QModelIndex) *core.QModelIndex { 177 | if !n.HasIndex(row, column, parent) { 178 | return core.NewQModelIndex() 179 | } 180 | 181 | var parentItem *NodeTreeItem 182 | if !parent.IsValid() { 183 | parentItem = n.rootItem 184 | } else { 185 | parentItem = NewNodeTreeItemFromPointer(parent.InternalPointer()) 186 | } 187 | 188 | childItem := parentItem.Child(row) 189 | if childItem != nil { 190 | return n.CreateIndex(row, column, childItem.Pointer()) 191 | } 192 | 193 | return core.NewQModelIndex() 194 | } 195 | 196 | // 标题信息 197 | func (n *NodeTreeModel) headerData(section int, orientation core.Qt__Orientation, role int) *core.QVariant { 198 | if role == int(core.Qt__DisplayRole) && orientation == core.Qt__Horizontal { 199 | return core.NewQVariant17("节点") 200 | } 201 | 202 | return n.HeaderDataDefault(section, orientation, role) 203 | } 204 | 205 | func (n *NodeTreeModel) parent(index *core.QModelIndex) *core.QModelIndex { 206 | if !index.IsValid() { 207 | return core.NewQModelIndex() 208 | } 209 | 210 | item := NewNodeTreeItemFromPointer(index.InternalPointer()) 211 | parentItem := item.ParentItem() 212 | 213 | if parentItem.Pointer() == n.rootItem.Pointer() { 214 | return core.NewQModelIndex() 215 | } 216 | 217 | return n.CreateIndex(parentItem.Row(), 0, parentItem.Pointer()) 218 | } 219 | 220 | func (n *NodeTreeModel) columnCount(_ *core.QModelIndex) int { 221 | return n.rootItem.ColumnCount() 222 | } 223 | 224 | // 每个节点的子节点数目 225 | func (n *NodeTreeModel) rowCount(parent *core.QModelIndex) int { 226 | if !parent.IsValid() { 227 | return n.rootItem.ChildCount() 228 | } 229 | 230 | return NewNodeTreeItemFromPointer(parent.InternalPointer()).ChildCount() 231 | } 232 | 233 | // view取得节点name 234 | func (n *NodeTreeModel) data(index *core.QModelIndex, role int) *core.QVariant { 235 | if !index.IsValid() { 236 | return core.NewQVariant() 237 | } 238 | 239 | item := NewNodeTreeItemFromPointer(index.InternalPointer()) 240 | // 处理顶层节点 241 | if item.parent.Data() == "" { 242 | switch role { 243 | case int(core.Qt__FontRole): 244 | font := gui.NewQFont2("noto color emoji", -1, -1, false) 245 | return core.NewQVariant3(int(core.QVariant__Font), font.Pointer()) 246 | case int(core.Qt__DisplayRole): 247 | if CountryFlags == nil { 248 | break 249 | } 250 | 251 | // 在顶层节点显示国旗 252 | if country, err := geoip.GetCountryISOCode(item.LatestChild(0).node.IP); err == nil { 253 | for i := range CountryFlags { 254 | if country == CountryFlags[i]["code"] { 255 | return core.NewQVariant17(CountryFlags[i]["emoji"] + " " + item.Data()) 256 | } 257 | } 258 | } 259 | } 260 | } 261 | 262 | switch role { 263 | case int(core.Qt__DisplayRole): 264 | return core.NewQVariant17(item.Data()) 265 | } 266 | 267 | return core.NewQVariant() 268 | } 269 | 270 | // FindNodeIndex 返回node所在的item的index 271 | func (n *NodeTreeModel) FindNodeIndex(node *parser.SSRNode) *core.QModelIndex { 272 | names := strings.Split(getGeoName(node.IP), "-") 273 | names = append(names, node.NameNumber()) 274 | 275 | parentItem := n.rootItem 276 | // 递归查找,找不到就返回无效index 277 | for _, name := range names { 278 | childItem := parentItem.FindChild(name) 279 | if childItem == nil { 280 | return core.NewQModelIndex() 281 | } 282 | 283 | if childItem.name == name { 284 | parentItem = childItem 285 | } 286 | } 287 | 288 | // 处理无children的情况 289 | if parentItem.Pointer() == n.rootItem.Pointer() { 290 | return core.NewQModelIndex() 291 | } 292 | 293 | return n.CreateIndex(parentItem.Row(), 0, parentItem.Pointer()) 294 | } 295 | -------------------------------------------------------------------------------- /widgets/notify.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "time" 8 | 9 | libnotify "github.com/mqu/go-notify" 10 | "github.com/therecipe/qt/core" 11 | ) 12 | 13 | const ( 14 | applicationName = "schannel-qt5" 15 | // 默认气泡框显示时间 16 | defaultNotifyDelay = 3 * time.Second 17 | ) 18 | 19 | // 图标文件对象缓存 20 | var iconFileData = make([]byte, 0) 21 | 22 | // ShowNotification 显示org.freedesktop.Notifications气泡消息框 23 | // duration == -1时使用默认delay 24 | // duration == 0表示不设置超时,desktop notification将会一直显示 25 | // 出错信息输出到stderr,不进入log 26 | func ShowNotification(title, text, image string, delay time.Duration) { 27 | var notifyDelay int32 28 | if delay == -1 { 29 | notifyDelay = duration2millisecond(defaultNotifyDelay) 30 | } else { 31 | notifyDelay = duration2millisecond(delay) 32 | // 不合法值(包括duration不足1ms),使用默认值进行替换 33 | if notifyDelay == -1 { 34 | notifyDelay = duration2millisecond(defaultNotifyDelay) 35 | } 36 | } 37 | 38 | libnotify.Init(applicationName) 39 | 40 | if len(iconFileData) == 0 { 41 | iconFile := core.NewQFile2(":/image/icon.svg") 42 | iconFile.Open(core.QIODevice__ReadOnly) 43 | iconFileData = append(iconFileData, iconFile.ReadAll().Data()...) 44 | iconFile.Close() 45 | } 46 | 47 | tmpIcon, err := ioutil.TempFile("", "schannel-qt5.*.svg") 48 | if err == nil { 49 | tmpIcon.Write(iconFileData) 50 | defer os.Remove(tmpIcon.Name()) 51 | defer tmpIcon.Close() 52 | if image == "" { 53 | image = tmpIcon.Name() 54 | } 55 | } 56 | 57 | notify := libnotify.NotificationNew(title, text, image) 58 | if notify == nil { 59 | fmt.Fprintf(os.Stderr, "Unable to create a new notification\n") 60 | return 61 | } 62 | notify.SetTimeout(notifyDelay) 63 | 64 | notify.Show() 65 | } 66 | 67 | // duration2millisecond 将time.Duration转换成millisecond 68 | // duration不足1ms将返回-1 69 | func duration2millisecond(duration time.Duration) int32 { 70 | res := int32(duration / time.Millisecond) 71 | if res < 0 { 72 | return -1 73 | } 74 | 75 | return res 76 | } 77 | -------------------------------------------------------------------------------- /widgets/progressbar.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "github.com/therecipe/qt/widgets" 5 | ) 6 | 7 | const ( 8 | // Red progressbar进度条背景色红色 9 | Red = "QProgressBar{text-align:center}QProgressBar::chunk{background:red}" 10 | // Green progressbar进度条背景色绿色 11 | Green = "QProgressBar{text-align:center}QProgressBar::chunk{background:green}" 12 | ) 13 | 14 | type ProgressBar struct { 15 | widgets.QProgressBar 16 | 17 | highMark int 18 | } 19 | 20 | // NewProgressBarWithMaxium 返回progressbar 21 | // 并且设置最小值为0,最大值为max 22 | // 设置当前值为current 23 | // 设置颜色变换标志位数值为mark 24 | func NewProgressBarWithMark(max, current, mark int) *ProgressBar { 25 | p := NewProgressBar(nil) 26 | p.SetMark(mark) 27 | p.SetRange(0, max) 28 | p.ConnectValueChanged(p.setColor) 29 | p.SetValue(current) 30 | 31 | return p 32 | } 33 | 34 | // setColor 根据新的value判断该选用哪种颜色 35 | // 小于等于highMark为绿色,大于则为红色 36 | func (p *ProgressBar) setColor(newValue int) { 37 | if newValue > p.highMark { 38 | p.SetStyleSheet(Red) 39 | return 40 | } 41 | p.SetStyleSheet(Green) 42 | } 43 | 44 | // SetMark 更改highmark 45 | func (p *ProgressBar) SetMark(mark int) { 46 | p.highMark = mark 47 | } 48 | -------------------------------------------------------------------------------- /widgets/savepath_recorder.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // SavePathRecorder 缓存用户上一次保存文件的目录,通过服务名称获取目录数据 10 | type SavePathRecorder struct { 11 | recorder map[string]string 12 | } 13 | 14 | // 记录用户上次保存文件的目录 15 | var pathRecorder = NewSavePathRecorder() 16 | 17 | func NewSavePathRecorder() *SavePathRecorder { 18 | recorder := &SavePathRecorder{ 19 | recorder: make(map[string]string), 20 | } 21 | 22 | return recorder 23 | } 24 | 25 | // LastSavePath 根据service返回上次保存文件的目录,service不存在则返回false 26 | func (r *SavePathRecorder) LastSavePath(service string) (string, bool) { 27 | path, ok := r.recorder[service] 28 | return path, ok 29 | } 30 | 31 | // SetLastSavePath 设置service上一次保存文件的目录 32 | // service不能为空 33 | func (r *SavePathRecorder) SetLastSavePath(service, path string) error { 34 | if service == "" { 35 | return errors.New("empty service") 36 | } 37 | 38 | path = filepath.Dir(path) 39 | r.recorder[service] = path 40 | return nil 41 | } 42 | 43 | // defaultSavePath 返回文件默认存储位置 44 | // 优先选择上次保存文件的目录(仅限本次会话期间) 45 | // 否则选择$HOME 46 | func defaultSavePath(service, fileName string) (string, error) { 47 | savePath, ok := pathRecorder.LastSavePath(service) 48 | if !ok { 49 | home := os.Getenv("HOME") 50 | if home == "" { 51 | return "", errors.New("无法获取HOME") 52 | } 53 | 54 | savePath = filepath.Join(home, fileName) 55 | } else { 56 | savePath = filepath.Join(savePath, fileName) 57 | } 58 | 59 | return savePath, nil 60 | } 61 | -------------------------------------------------------------------------------- /widgets/service_panel.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/therecipe/qt/core" 7 | "github.com/therecipe/qt/widgets" 8 | 9 | "schannel-qt5/parser" 10 | ) 11 | 12 | // ServicePanel 显示服务信息 13 | type ServicePanel struct { 14 | widgets.QWidget 15 | 16 | // 服务信息 17 | serviceName *widgets.QLabel 18 | user *widgets.QLabel 19 | port *widgets.QLabel 20 | password *widgets.QLabel 21 | payment *widgets.QLabel 22 | expireDate *widgets.QLabel 23 | serviceState *widgets.QLabel 24 | } 25 | 26 | // NewServicePanel2 根据service生成服务信息面板 27 | func NewServicePanel2(user string, info *parser.SSRInfo) *ServicePanel { 28 | if info == nil { 29 | return nil 30 | } 31 | panel := NewServicePanel(nil, 0) 32 | panel.InitUI(user, info) 33 | 34 | return panel 35 | } 36 | 37 | // InitUI 初始化UI组件 38 | func (panel *ServicePanel) InitUI(user string, info *parser.SSRInfo) { 39 | group := widgets.NewQGroupBox2("服务信息:", nil) 40 | 41 | serviceNameLabel := widgets.NewQLabel2("服务名称:", nil, 0) 42 | panel.serviceName = widgets.NewQLabel(nil, 0) 43 | userLabel := widgets.NewQLabel2("用户:", nil, 0) 44 | panel.user = widgets.NewQLabel(nil, 0) 45 | panel.user.SetTextInteractionFlags(core.Qt__TextSelectableByMouse) 46 | portLabel := widgets.NewQLabel2("端口:", nil, 0) 47 | panel.port = widgets.NewQLabel(nil, 0) 48 | panel.port.SetTextInteractionFlags(core.Qt__TextSelectableByMouse) 49 | passwordLabel := widgets.NewQLabel2("密码:", nil, 0) 50 | panel.password = widgets.NewQLabel(nil, 0) 51 | panel.password.SetTextInteractionFlags(core.Qt__TextSelectableByMouse) 52 | paymentLabel := widgets.NewQLabel2("费用:", nil, 0) 53 | panel.payment = widgets.NewQLabel(nil, 0) 54 | expireLabel := widgets.NewQLabel2("过期时间:", nil, 0) 55 | panel.expireDate = widgets.NewQLabel(nil, 0) 56 | serviceStateLabel := widgets.NewQLabel2("服务状态:", nil, 0) 57 | panel.serviceState = widgets.NewQLabel(nil, 0) 58 | 59 | infoLayout := widgets.NewQGridLayout2() 60 | infoLayout.AddWidget(serviceNameLabel, 0, 0, 0) 61 | infoLayout.AddWidget(panel.serviceName, 0, 1, 0) 62 | infoLayout.AddWidget(userLabel, 1, 0, 0) 63 | infoLayout.AddWidget(panel.user, 1, 1, 0) 64 | infoLayout.AddWidget(portLabel, 2, 0, 0) 65 | infoLayout.AddWidget(panel.port, 2, 1, 0) 66 | infoLayout.AddWidget(passwordLabel, 3, 0, 0) 67 | infoLayout.AddWidget(panel.password, 3, 1, 0) 68 | infoLayout.AddWidget(paymentLabel, 4, 0, 0) 69 | infoLayout.AddWidget(panel.payment, 4, 1, 0) 70 | infoLayout.AddWidget(expireLabel, 5, 0, 0) 71 | infoLayout.AddWidget(panel.expireDate, 5, 1, 0) 72 | infoLayout.AddWidget(serviceStateLabel, 6, 0, 0) 73 | infoLayout.AddWidget(panel.serviceState, 6, 1, 0) 74 | 75 | group.SetLayout(infoLayout) 76 | mainLayout := widgets.NewQVBoxLayout() 77 | mainLayout.AddWidget(group, 0, 0) 78 | panel.SetLayout(mainLayout) 79 | 80 | // 向panel填充信息 81 | panel.UpadteInfo(user, info) 82 | } 83 | 84 | // UpdateInfo 更新panel信息 85 | func (panel *ServicePanel) UpadteInfo(user string, info *parser.SSRInfo) { 86 | panel.user.SetText(user) 87 | panel.serviceName.SetText(info.Name) 88 | panel.port.SetText(fmt.Sprint(info.Port)) 89 | panel.password.SetText(info.Passwd) 90 | panel.payment.SetText(info.Price) 91 | panel.expireDate.SetText(time2string(info.Expires)) 92 | panel.serviceState.SetText(info.State) 93 | } 94 | -------------------------------------------------------------------------------- /widgets/shade_widget.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "github.com/therecipe/qt/core" 5 | "github.com/therecipe/qt/gui" 6 | "github.com/therecipe/qt/widgets" 7 | ) 8 | 9 | // ShadeWidget 半透明遮罩层 10 | type ShadeWidget struct { 11 | widgets.QWidget 12 | } 13 | 14 | // NewShadeWidget2 返回parent的遮罩,将会遮住parent,Close后资源自动释放 15 | // 创建后自动调用Show() 16 | func NewShadeWidget2(parent *widgets.QWidget) *ShadeWidget { 17 | shade := NewShadeWidget(parent, 0) 18 | // 子控件设置Qt::WA_StyledBackground后才可设置背景 19 | shade.SetAttribute(core.Qt__WA_StyledBackground, true) 20 | shade.SetAttribute(core.Qt__WA_DeleteOnClose, true) 21 | // alpha max is 255, so 40% is 102 22 | shade.SetStyleSheet("background-color:rgba(0,0,0,102);") 23 | shade.SetWindowFlags(core.Qt__FramelessWindowHint) 24 | shade.SetGeometry2(0, 0, shade.ParentWidget().Width(), shade.ParentWidget().Height()) 25 | // 防止parent最大化后遮罩不能完全遮盖parent(部分窗口管理器中模态对话框的父窗口仍可最大化) 26 | parent.ConnectResizeEvent(func(event *gui.QResizeEvent) { 27 | shade.SetGeometry2(0, 0, shade.ParentWidget().Width(), shade.ParentWidget().Height()) 28 | }) 29 | shade.Show() 30 | 31 | return shade 32 | } 33 | -------------------------------------------------------------------------------- /widgets/ssr_node_detail_widget.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/therecipe/qt/core" 7 | "github.com/therecipe/qt/widgets" 8 | 9 | "schannel-qt5/parser" 10 | ) 11 | 12 | // NodeDetailWidget 显示节点的详细信息 13 | type NodeDetailWidget struct { 14 | widgets.QWidget 15 | 16 | // 节点名称 17 | name *widgets.QLabel 18 | // 代理类型 19 | proxyType *widgets.QLabel 20 | // IP和端口信息 21 | ip *widgets.QLabel 22 | port *widgets.QLabel 23 | password *widgets.QLabel 24 | // 加密算法 25 | crypt *widgets.QLabel 26 | // 混淆算法 27 | mixin *widgets.QLabel 28 | // 连接协议 29 | proto *widgets.QLabel 30 | // 服务器地理信息 31 | geo *widgets.QLabel 32 | } 33 | 34 | // NewNodeDetailWidgetWithNode 根据参数给出的节点显示其详细信息 35 | func NewNodeDetailWidgetWithNode(node *parser.SSRNode) *NodeDetailWidget { 36 | n := NewNodeDetailWidget(nil, 0) 37 | 38 | n.InitUI() 39 | n.SetNodeDetail(node) 40 | 41 | return n 42 | } 43 | 44 | // InitUI 初始化UI 45 | func (n *NodeDetailWidget) InitUI() { 46 | mainLayout := widgets.NewQGridLayout2() 47 | title := widgets.NewQLabel2("节点信息", nil, 0) 48 | titleFontSize := float64(title.FontMetrics().AverageCharWidth()) * 1.5 49 | title.Font().SetPixelSize(int(titleFontSize)) 50 | titleAlign := core.Qt__AlignHCenter | core.Qt__AlignTop 51 | mainLayout.AddWidget3(title, 0, 0, 1, 2, titleAlign) 52 | 53 | nameLabel := widgets.NewQLabel2("节点名称:", nil, 0) 54 | n.name = widgets.NewQLabel(nil, 0) 55 | mainLayout.AddWidget(nameLabel, 1, 0, 0) 56 | mainLayout.AddWidget(n.name, 1, 1, 0) 57 | 58 | proxyLabel := widgets.NewQLabel2("代理类型:", nil, 0) 59 | n.proxyType = widgets.NewQLabel(nil, 0) 60 | mainLayout.AddWidget(proxyLabel, 2, 0, 0) 61 | mainLayout.AddWidget(n.proxyType, 2, 1, 0) 62 | 63 | ipLabel := widgets.NewQLabel2("IP:", nil, 0) 64 | n.ip = widgets.NewQLabel(nil, 0) 65 | mainLayout.AddWidget(ipLabel, 3, 0, 0) 66 | mainLayout.AddWidget(n.ip, 3, 1, 0) 67 | portLabel := widgets.NewQLabel2("端口:", nil, 0) 68 | n.port = widgets.NewQLabel(nil, 0) 69 | mainLayout.AddWidget(portLabel, 4, 0, 0) 70 | mainLayout.AddWidget(n.port, 4, 1, 0) 71 | passwordLabel := widgets.NewQLabel2("密码:", nil, 0) 72 | n.password = widgets.NewQLabel(nil, 0) 73 | mainLayout.AddWidget(passwordLabel, 5, 0, 0) 74 | mainLayout.AddWidget(n.password, 5, 1, 0) 75 | 76 | cryptLabel := widgets.NewQLabel2("加密算法:", nil, 0) 77 | n.crypt = widgets.NewQLabel(nil, 0) 78 | mainLayout.AddWidget(cryptLabel, 6, 0, 0) 79 | mainLayout.AddWidget(n.crypt, 6, 1, 0) 80 | 81 | mixinLabel := widgets.NewQLabel2("混淆算法:", nil, 0) 82 | n.mixin = widgets.NewQLabel(nil, 0) 83 | mainLayout.AddWidget(mixinLabel, 7, 0, 0) 84 | mainLayout.AddWidget(n.mixin, 7, 1, 0) 85 | 86 | protoLabel := widgets.NewQLabel2("连接协议:", nil, 0) 87 | n.proto = widgets.NewQLabel(nil, 0) 88 | mainLayout.AddWidget(protoLabel, 8, 0, 0) 89 | mainLayout.AddWidget(n.proto, 8, 1, 0) 90 | 91 | geoLabel := widgets.NewQLabel2("国家/地区:", nil, 0) 92 | n.geo = widgets.NewQLabel(nil, 0) 93 | mainLayout.AddWidget(geoLabel, 9, 0, 0) 94 | mainLayout.AddWidget(n.geo, 9, 1, 0) 95 | 96 | n.SetLayout(mainLayout) 97 | } 98 | 99 | // SetNodeDetail 设置需要显示详细信息的节点 100 | func (n *NodeDetailWidget) SetNodeDetail(node *parser.SSRNode) { 101 | if node == nil { 102 | return 103 | } 104 | 105 | n.name.SetText(node.NodeName) 106 | n.proxyType.SetText(node.Type) 107 | n.ip.SetText(node.IP) 108 | port := fmt.Sprintf("%v", node.Port) 109 | n.port.SetText(port) 110 | n.password.SetText(node.Passwd) 111 | n.crypt.SetText(node.Crypto) 112 | n.mixin.SetText(node.Minx) 113 | n.proto.SetText(node.Proto) 114 | n.geo.SetText(getGeoName(node.IP)) 115 | } 116 | -------------------------------------------------------------------------------- /widgets/ssr_node_info_panel.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "github.com/therecipe/qt/core" 5 | "github.com/therecipe/qt/widgets" 6 | 7 | "schannel-qt5/parser" 8 | ) 9 | 10 | // NodeInfoPanel ssr节点简略信息面板 11 | // 显示: 名字,ip,国家(根据节点名称得出) 12 | type NodeInfoPanel struct { 13 | widgets.QGroupBox 14 | 15 | // 处理数据更新,重新计算摘要信息 16 | _ func(node *parser.SSRNode) `signal:"dataRefresh,auto"` 17 | 18 | // 节点名字 19 | nodeNameLabel *widgets.QLabel 20 | // ip 21 | ipLabel *widgets.QLabel 22 | // 国家信息 23 | geoInfoLabel *widgets.QLabel 24 | 25 | node *parser.SSRNode 26 | } 27 | 28 | // NewNodeInfoPanelWithNode 根据node参数生成简略信息面板 29 | func NewNodeInfoPanelWithNode(node *parser.SSRNode) *NodeInfoPanel { 30 | panel := NewNodeInfoPanel(nil) 31 | panel.node = node 32 | panel.InitUI() 33 | 34 | return panel 35 | } 36 | 37 | // InitUI 初始化UI 38 | func (n *NodeInfoPanel) InitUI() { 39 | n.SetTitle("ssr摘要:") 40 | 41 | geoInfo := getGeoName(n.node.IP) 42 | n.geoInfoLabel = widgets.NewQLabel2(geoInfo, nil, 0) 43 | n.ipLabel = widgets.NewQLabel2(n.node.IP, nil, 0) 44 | n.ipLabel.SetTextInteractionFlags(core.Qt__TextSelectableByMouse) 45 | n.nodeNameLabel = widgets.NewQLabel2(n.node.NodeName, nil, 0) 46 | n.nodeNameLabel.SetTextInteractionFlags(core.Qt__TextSelectableByMouse) 47 | 48 | mainLayout := widgets.NewQGridLayout2() 49 | name := widgets.NewQLabel2("节点名称:", nil, 0) 50 | mainLayout.AddWidget(name, 0, 0, 0) 51 | mainLayout.AddWidget(n.nodeNameLabel, 0, 1, 0) 52 | ip := widgets.NewQLabel2("IP:", nil, 0) 53 | mainLayout.AddWidget(ip, 1, 0, 0) 54 | mainLayout.AddWidget(n.ipLabel, 1, 1, 0) 55 | geo := widgets.NewQLabel2("地区/国家:", nil, 0) 56 | mainLayout.AddWidget(geo, 2, 0, 0) 57 | mainLayout.AddWidget(n.geoInfoLabel, 2, 1, 0) 58 | 59 | n.SetLayout(mainLayout) 60 | } 61 | 62 | // dataRefresh 刷新节点摘要信息 63 | func (n *NodeInfoPanel) dataRefresh(node *parser.SSRNode) { 64 | n.node = node 65 | geoInfo := getGeoName(n.node.IP) 66 | n.nodeNameLabel.SetText(n.node.NodeName) 67 | n.ipLabel.SetText(n.node.IP) 68 | n.geoInfoLabel.SetText(geoInfo) 69 | } 70 | -------------------------------------------------------------------------------- /widgets/ssr_node_select_dialog.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/therecipe/qt/core" 9 | "github.com/therecipe/qt/widgets" 10 | 11 | "schannel-qt5/parser" 12 | ) 13 | 14 | // NodeSelectDialog 显示所有节点信息,并选择设置节点 15 | // 需要使用模态运行 16 | type NodeSelectDialog struct { 17 | widgets.QDialog 18 | 19 | // dialog功能按钮 20 | okButton, cancelButton *widgets.QPushButton 21 | // 节点列表 22 | //list *widgets.QListWidget 23 | tree *widgets.QTreeView 24 | nodeModel *NodeTreeModel 25 | // node详细信息 26 | detail *NodeDetailWidget 27 | 28 | // 选择的节点,将作为结果被使用 29 | CurrentNode *parser.SSRNode 30 | } 31 | 32 | // NewNodeSelectDialog2 生成node选择对话框 33 | func NewNodeSelectDialog2(current *parser.SSRNode, nodes []*parser.SSRNode) *NodeSelectDialog { 34 | if nodes == nil { 35 | return nil 36 | } 37 | 38 | dialog := NewNodeSelectDialog(nil, 0) 39 | dialog.CurrentNode = current 40 | // dialog运行于模态,nodes不会被修改 41 | dialog.nodeModel = NewNodeTreeModel2(nodes) 42 | dialog.InitUI() 43 | 44 | return dialog 45 | } 46 | 47 | // InitUI 初始化界面 48 | func (dialog *NodeSelectDialog) InitUI() { 49 | dialog.detail = NewNodeDetailWidgetWithNode(dialog.CurrentNode) 50 | 51 | dialog.tree = widgets.NewQTreeView(nil) 52 | dialog.tree.SetModel(dialog.nodeModel) 53 | dialog.tree.SetAnimated(true) 54 | // 设置选择当前节点 55 | currentIndex := dialog.nodeModel.FindNodeIndex(dialog.CurrentNode) 56 | dialog.tree.SetCurrentIndex(currentIndex) 57 | dialog.tree.Expand(currentIndex) 58 | dialog.tree.ConnectClicked(func(index *core.QModelIndex) { 59 | item := NewNodeTreeItemFromPointer(index.InternalPointer()) 60 | if item.ChildCount() == 0 { 61 | dialog.detail.SetNodeDetail(item.Node()) 62 | dialog.CurrentNode = item.Node() 63 | } else { 64 | nodeItem := item.LatestChild(0) 65 | dialog.detail.SetNodeDetail(nodeItem.Node()) 66 | dialog.CurrentNode = nodeItem.Node() 67 | } 68 | }) 69 | dialog.tree.Clicked(currentIndex) 70 | 71 | dialog.okButton = widgets.NewQPushButton2("选择", nil) 72 | dialog.okButton.ConnectClicked(func(_ bool) { 73 | dialog.Accept() 74 | }) 75 | dialog.cancelButton = widgets.NewQPushButton2("取消", nil) 76 | dialog.cancelButton.ConnectClicked(func(_ bool) { 77 | dialog.Reject() 78 | }) 79 | saveNodeButton := widgets.NewQPushButton2("保存至文件", nil) 80 | saveNodeButton.ConnectClicked(dialog.saveNode) 81 | 82 | mainLayout := widgets.NewQGridLayout2() 83 | contentLayout := widgets.NewQHBoxLayout() 84 | contentLayout.AddWidget(dialog.tree, 1, 0) 85 | contentLayout.AddWidget(dialog.detail, 2, 0) 86 | mainLayout.AddLayout2(contentLayout, 0, 0, 1, 4, 0) 87 | // 水平分割线 88 | hFrame := widgets.NewQFrame(nil, 0) 89 | hFrame.SetFrameStyle(int(widgets.QFrame__HLine) | int(widgets.QFrame__Sunken)) 90 | mainLayout.AddWidget3(hFrame, 1, 0, 1, 4, 0) 91 | mainLayout.AddWidget(saveNodeButton, 2, 1, 0) 92 | mainLayout.AddWidget(dialog.cancelButton, 2, 2, 0) 93 | mainLayout.AddWidget(dialog.okButton, 2, 3, 0) 94 | dialog.SetLayout(mainLayout) 95 | dialog.SetWindowTitle("选择节点") 96 | } 97 | 98 | // saveNode 保存节点信息至文件 99 | func (dialog *NodeSelectDialog) saveNode(_ bool) { 100 | jsonFileFilter := "JSON Files(*.json)" 101 | nodeFileName := fmt.Sprintf("%s.json", dialog.CurrentNode.NodeName) 102 | savePath, err := getFileSavePath("node", nodeFileName, jsonFileFilter, dialog) 103 | if err == ErrCanceled { 104 | return 105 | } else if err != nil { 106 | showErrorDialog("保存路径获取失败:"+err.Error(), dialog) 107 | return 108 | } 109 | 110 | data, err := json.MarshalIndent(dialog.CurrentNode, "", "\t") 111 | if err != nil { 112 | showErrorDialog("配置解析失败:"+err.Error(), dialog) 113 | return 114 | } 115 | f, err := os.OpenFile(savePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) 116 | if err != nil { 117 | showErrorDialog("文件创建失败:"+err.Error(), dialog) 118 | return 119 | } 120 | defer f.Close() 121 | _, err = f.Write(data) 122 | if err != nil { 123 | showErrorDialog("写入配置失败:"+err.Error(), dialog) 124 | return 125 | } 126 | 127 | ShowNotification("节点", savePath+"保存成功", "", -1) 128 | } 129 | -------------------------------------------------------------------------------- /widgets/ssr_switch_panel.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "sort" 7 | "time" 8 | 9 | "github.com/therecipe/qt/widgets" 10 | 11 | "schannel-qt5/config" 12 | "schannel-qt5/parser" 13 | _ "schannel-qt5/pyclient" 14 | "schannel-qt5/ssr" 15 | ) 16 | 17 | // SSRSwitchPanel 显示简略信息,开关ssr 18 | type SSRSwitchPanel struct { 19 | widgets.QWidget 20 | 21 | // node缩略信息 22 | nodeInfo *NodeInfoPanel 23 | 24 | // 链接是否可用 25 | // 未打开客户端为"未开启"(gray) 26 | // 链接可用为"OK"(green) 27 | // 不可用为"error: [error info]"(red) 28 | connStat *ColorLabel 29 | 30 | // ssr开关 31 | switchButton *SwitchButton 32 | // 选择节点对话框按钮 33 | selectNodeButton *widgets.QPushButton 34 | 35 | // 当前使用的节点 36 | currentNode *parser.SSRNode 37 | // ssr client程序和配置文件 38 | ssrClient ssr.Launcher 39 | conf *config.UserConfig 40 | // 可用节点信息 41 | nodes []*parser.SSRNode 42 | 43 | logger *log.Logger 44 | } 45 | 46 | // NewSSRSwitchPanel2 创建ssr开关面板组件 47 | func NewSSRSwitchPanel2(conf *config.UserConfig, nodes []*parser.SSRNode, logger *log.Logger) *SSRSwitchPanel { 48 | if conf == nil || nodes == nil { 49 | return nil 50 | } 51 | panel := NewSSRSwitchPanel(nil, 0) 52 | 53 | panel.conf = conf 54 | panel.nodes = make([]*parser.SSRNode, len(nodes)) 55 | copy(panel.nodes, nodes) 56 | panel.SortNode() 57 | panel.logger = logger 58 | 59 | panel.ssrClient = ssr.NewLauncher("python", panel.conf) 60 | if panel.ssrClient == nil { 61 | panel.logger.Println("ssr client created failed") 62 | return nil 63 | } 64 | 65 | panel.currentNode = &parser.SSRNode{} 66 | nodePath, err := panel.conf.SSRNodeConfigPath.AbsPath() 67 | if err != nil { 68 | panel.logger.Println("node load failed: ", err) 69 | return nil 70 | } 71 | panel.currentNode.Load(nodePath) 72 | 73 | panel.InitUI() 74 | return panel 75 | } 76 | 77 | // SortNode 将节点排序,方便查找节点信息 78 | // 按照NodeName排序 79 | func (s *SSRSwitchPanel) SortNode() { 80 | sort.Slice(s.nodes, func(i, j int) bool { 81 | return s.nodes[i].NodeName < s.nodes[j].NodeName 82 | }) 83 | } 84 | 85 | // InitUI 初始化界面 86 | func (s *SSRSwitchPanel) InitUI() { 87 | componentLayout := widgets.NewQGridLayout2() 88 | 89 | s.nodeInfo = NewNodeInfoPanelWithNode(s.currentNode) 90 | componentLayout.AddWidget3(s.nodeInfo, 0, 0, 1, 3, 0) 91 | 92 | s.connStat = NewColorLabelWithColor("", "") 93 | // 设置自动换行 94 | s.connStat.AdjustSize() 95 | s.connStat.SetWordWrap(true) 96 | s.setConnStat() 97 | connStatLabel := widgets.NewQLabel2("连接状态:", nil, 0) 98 | componentLayout.AddWidget(connStatLabel, 1, 0, 0) 99 | componentLayout.AddWidget3(s.connStat, 1, 1, 1, 2, 0) 100 | 101 | s.switchButton = NewSwitchButton2(s.ssrClient.IsRunning() == nil) 102 | s.switchButton.ConnectClicked(func(checked bool) { 103 | info := "" 104 | switch checked { 105 | case true: 106 | if err := s.ssrClient.Start(); err != nil { 107 | errInfo := fmt.Sprintf("启动客户端错误: %v", err) 108 | showErrorDialog(errInfo, s) 109 | return 110 | } 111 | 112 | info = "已打开" 113 | case false: 114 | if err := s.ssrClient.Stop(); err != nil { 115 | errInfo := fmt.Sprintf("关闭客户端错误: %v", err) 116 | showErrorDialog(errInfo, s) 117 | return 118 | } 119 | 120 | info = "已关闭" 121 | } 122 | 123 | ShowNotification("SSR客户端", info, "", -1) 124 | s.setConnStat() 125 | }) 126 | switchLabel := widgets.NewQLabel2("ssr开关:", nil, 0) 127 | componentLayout.AddWidget(switchLabel, 2, 0, 0) 128 | componentLayout.AddWidget3(s.switchButton, 2, 1, 1, 2, 0) 129 | 130 | s.selectNodeButton = widgets.NewQPushButton2("选择节点", nil) 131 | s.selectNodeButton.ConnectClicked(func(_ bool) { 132 | dialog := NewNodeSelectDialog2(s.currentNode, s.nodes) 133 | shade := NewShadeWidget2(s.NativeParentWidget()) 134 | if dialog.Exec() == int(widgets.QDialog__Accepted) { 135 | s.currentNode = dialog.CurrentNode 136 | s.nodeInfo.DataRefresh(s.currentNode) 137 | } 138 | shade.Close() 139 | // goqt无法自动释放QWidget 140 | // 且此处不适合DeleteOnClose,所以需要手动调用DestroyNodeSelectDialog 141 | dialog.DestroyNodeSelectDialog() 142 | }) 143 | componentLayout.AddWidget3(s.selectNodeButton, 3, 2, 1, 1, 0) 144 | 145 | s.SetLayout(componentLayout) 146 | } 147 | 148 | // setConnStat 设置代理节点是否可用的信息 149 | func (s *SSRSwitchPanel) setConnStat() { 150 | if err := s.ssrClient.IsRunning(); err != nil { 151 | s.connStat.SetColorText("未开启客户端", "gray") 152 | return 153 | } 154 | 155 | if err := s.ssrClient.ConnectionCheck(5 * time.Second); err != nil { 156 | errInfo := fmt.Sprintf("error: %v", err) 157 | s.connStat.SetColorText(errInfo, "red") 158 | s.logger.Println(errInfo) 159 | ShowNotification("SSR连接测试失败", errInfo, "", -1) 160 | return 161 | } 162 | 163 | s.connStat.SetColorText("OK", "green") 164 | } 165 | 166 | // DataRefresh 更新config和nodes 167 | func (s *SSRSwitchPanel) DataRefresh(conf *config.UserConfig, nodes []*parser.SSRNode) { 168 | // 停止旧的客户端运行 169 | if running := s.ssrClient.IsRunning(); running == nil { 170 | s.ssrClient.Stop() 171 | s.switchButton.SetChecked(false) 172 | ShowNotification("SSR客户端", "已关闭", "", -1) 173 | } 174 | s.conf = conf 175 | s.ssrClient = ssr.NewLauncher("python", s.conf) 176 | if s.ssrClient == nil { 177 | s.logger.Println("ssr switch DataRefresh: 初始化ssr客户端错误") 178 | // TODO 更详细的错误信息 179 | showErrorDialog("初始化ssr客户端错误", s) 180 | return 181 | } 182 | 183 | s.nodes = make([]*parser.SSRNode, len(nodes)) 184 | copy(s.nodes, nodes) 185 | s.SortNode() 186 | 187 | //TODO 检测当前节点不在节点列表的情况 188 | s.currentNode = &parser.SSRNode{} 189 | nodeConfigPath, err := s.conf.SSRNodeConfigPath.AbsPath() 190 | if err != nil { 191 | // 节点配置获取失败,错误信息用信息框显示 192 | showErrorDialog(err.Error(), s) 193 | return 194 | } 195 | s.currentNode.Load(nodeConfigPath) 196 | s.nodeInfo.DataRefresh(s.currentNode) 197 | s.setConnStat() 198 | } 199 | -------------------------------------------------------------------------------- /widgets/ssrclient_config_widget.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/therecipe/qt/widgets" 8 | 9 | "schannel-qt5/config" 10 | ) 11 | 12 | // 设置ssr client 13 | type SSRConfigWidget struct { 14 | widgets.QWidget 15 | 16 | // 通知配置值发生变化 17 | _ func() `signal:"valueChanged"` 18 | 19 | // 本地监听地址 20 | localAddr *widgets.QLineEdit 21 | localAddrMsg *ColorLabel 22 | localPort *widgets.QSpinBox 23 | localPortMsg *ColorLabel 24 | 25 | // pid-file存放路径 26 | pidFilePath *widgets.QLineEdit 27 | pidFilePathMsg *ColorLabel 28 | 29 | // 是否使用fast-open 30 | fastOpen *widgets.QCheckBox 31 | 32 | conf config.ClientConfig 33 | } 34 | 35 | // NewSSRConfigWidget2 创建ssr client config widget 36 | func NewSSRConfigWidget2(conf config.ClientConfig) *SSRConfigWidget { 37 | widget := NewSSRConfigWidget(nil, 0) 38 | widget.conf = conf 39 | widget.InitUI() 40 | 41 | return widget 42 | } 43 | 44 | // InitUI 初始化界面 45 | func (s *SSRConfigWidget) InitUI() { 46 | group := widgets.NewQGroupBox2("ssr client设置", nil) 47 | groupLayout := widgets.NewQFormLayout(nil) 48 | 49 | s.localAddr = widgets.NewQLineEdit(nil) 50 | s.localAddr.SetPlaceholderText("绑定本地ip地址") 51 | s.localAddr.SetText(s.conf.LocalAddr()) 52 | s.localAddr.ConnectTextChanged(func(_ string) { 53 | s.ValueChanged() 54 | }) 55 | s.localAddrMsg = NewColorLabelWithColor("不是合法的ip地址", "red") 56 | s.localAddrMsg.Hide() 57 | groupLayout.AddRow3("本地地址:", s.localAddr) 58 | groupLayout.AddRow5(s.localAddrMsg) 59 | 60 | s.localPort = widgets.NewQSpinBox(nil) 61 | // 端口从1024-65535 62 | s.localPort.SetRange(1024, 65535) 63 | port, _ := strconv.Atoi(s.conf.LocalPort()) 64 | s.localPort.SetValue(port) 65 | s.localPort.ConnectValueChanged(func(_ int) { 66 | s.ValueChanged() 67 | }) 68 | s.localPortMsg = NewColorLabelWithColor("不是合法的端口值", "red") 69 | s.localPortMsg.Hide() 70 | groupLayout.AddRow3("本地端口:", s.localPort) 71 | groupLayout.AddRow5(s.localPortMsg) 72 | 73 | s.pidFilePath = widgets.NewQLineEdit(nil) 74 | s.pidFilePath.SetPlaceholderText("绝对路径") 75 | s.pidFilePath.SetText(s.conf.PidFilePath()) 76 | s.pidFilePath.ConnectTextChanged(func(_ string) { 77 | s.ValueChanged() 78 | }) 79 | s.pidFilePathMsg = NewColorLabelWithColor("不是合法的路径", "red") 80 | s.pidFilePathMsg.Hide() 81 | groupLayout.AddRow3("pid-file路径:", s.pidFilePath) 82 | groupLayout.AddRow5(s.pidFilePathMsg) 83 | 84 | // 检查内核版本 85 | versionInfo := widgets.NewQLabel(nil, 0) 86 | s.fastOpen = widgets.NewQCheckBox2("启用fast-open", nil) 87 | s.fastOpen.SetToolTip("此功能需要Linux kernel版本 >= 3.7.0") 88 | s.fastOpen.SetEnabled(false) 89 | if version, err := kernelVersion(); err != nil { 90 | versionInfo.SetText(err.Error()) 91 | } else if fastOpenAble(version) { 92 | s.fastOpen.SetEnabled(true) 93 | s.fastOpen.SetChecked(s.conf.FastOpen()) 94 | versionInfo.SetText(fmt.Sprintf("Linux kernel: %v", version)) 95 | } else { 96 | versionInfo.SetText(fmt.Sprintf("内核版本不支持fast-open: %v", version)) 97 | } 98 | s.fastOpen.ConnectClicked(func(_ bool) { 99 | s.ValueChanged() 100 | }) 101 | groupLayout.AddRow5(s.fastOpen) 102 | groupLayout.AddRow5(versionInfo) 103 | 104 | group.SetLayout(groupLayout) 105 | mainLayout := widgets.NewQVBoxLayout() 106 | mainLayout.AddWidget(group, 0, 0) 107 | s.SetLayout(mainLayout) 108 | } 109 | 110 | // UpdateSSRClientConfig 更新config,如果数据不合法则返回error 111 | // 因为传递了引用类型,所以直接修改config对象 112 | func (s *SSRConfigWidget) UpdateSSRClientConfig() error { 113 | // 可选时才设置fast-open值 114 | if s.fastOpen.IsEnabled() { 115 | s.conf.SetFastOpen(s.fastOpen.IsChecked()) 116 | } 117 | 118 | // 记录返回值 119 | var err error 120 | var errRes error 121 | err = s.conf.SetLocalPort(strconv.Itoa(s.localPort.Value())) 122 | if showErrorMsg(s.localPortMsg, err) { 123 | errRes = err 124 | } 125 | 126 | err = s.conf.SetLocalAddr(s.localAddr.Text()) 127 | if showErrorMsg(s.localAddrMsg, err) { 128 | errRes = err 129 | } 130 | 131 | err = s.conf.SetPidFilePath(s.pidFilePath.Text()) 132 | if showErrorMsg(s.pidFilePathMsg, err) { 133 | errRes = err 134 | } 135 | 136 | return errRes 137 | } 138 | -------------------------------------------------------------------------------- /widgets/summarized_widget.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "compress/gzip" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/therecipe/qt/core" 13 | "github.com/therecipe/qt/widgets" 14 | 15 | "schannel-qt5/config" 16 | "schannel-qt5/geoip" 17 | "schannel-qt5/parser" 18 | ) 19 | 20 | // SummarizedWidget 综合服务信息显示,包括用户信息,服务信息 21 | type SummarizedWidget struct { 22 | widgets.QWidget 23 | 24 | // 收到数据变动 25 | _ func() `signal:"dataRefresh,auto"` 26 | // 发出数据变动,让上层控件更新service 27 | // 上层控件完成service的更新后发送DataRefresh信号,int值为当前的index 28 | _ func(int) `signal:"serviceNeedUpdate"` 29 | 30 | // 用户数据接口 31 | dataBridge UserDataBridge 32 | 33 | // 服务信息面板 34 | servicePanel *ServicePanel 35 | // ssr开关面板 36 | switchPanel *SSRSwitchPanel 37 | // 使用量信息 38 | usedPanel *UsedPanel 39 | // 是否需要付款 40 | invoicePanel *InvoicePanel 41 | // 下载GeoIP Database 42 | getGeoButton *widgets.QPushButton 43 | 44 | // 用户名-email 45 | user string 46 | // 用户配置 47 | conf *config.UserConfig 48 | // 服务信息 49 | service *parser.Service 50 | // 综合信息面板编号,因为可能不止一个服务,所以用来做身份区别 51 | // index与services数组中的索引相同 52 | index int 53 | } 54 | 55 | // NewSummarizedWidget2 创建综合信息控件 56 | func NewSummarizedWidget2(index int, 57 | user string, 58 | service *parser.Service, 59 | conf *config.UserConfig, 60 | dataBridge UserDataBridge) *SummarizedWidget { 61 | if user == "" || dataBridge == nil { 62 | return nil 63 | } 64 | sw := NewSummarizedWidget(nil, 0) 65 | 66 | sw.user = user 67 | sw.dataBridge = dataBridge 68 | sw.service = service 69 | sw.conf = conf 70 | sw.index = index 71 | sw.InitUI() 72 | 73 | return sw 74 | } 75 | 76 | // InitUI 初始化UI 77 | func (sw *SummarizedWidget) InitUI() { 78 | ssrInfo := sw.dataBridge.SSRInfos(sw.service) 79 | logger := sw.dataBridge.GetLogger() 80 | sw.servicePanel = NewServicePanel2(sw.user, ssrInfo) 81 | sw.invoicePanel = NewInvoicePanelWithData(sw.dataBridge) 82 | sw.switchPanel = NewSSRSwitchPanel2(sw.conf, ssrInfo.Nodes, logger) 83 | sw.usedPanel = NewUsedPanelWithInfo(sw.user, ssrInfo, logger) 84 | 85 | updateButton := widgets.NewQPushButton2("刷新", nil) 86 | // 通知上层控件更新sw的service 87 | updateButton.ConnectClicked(func(_ bool) { 88 | sw.ServiceNeedUpdate(sw.index) 89 | }) 90 | leftLayout := widgets.NewQVBoxLayout() 91 | leftLayout.AddWidget(sw.servicePanel, 0, 0) 92 | leftLayout.AddWidget(sw.invoicePanel, 0, 0) 93 | leftLayout.AddStretch(0) 94 | buttonLayout := widgets.NewQHBoxLayout() 95 | buttonLayout.AddStretch(0) 96 | 97 | geoPath, err := geoip.GetGeoIPSavePath() 98 | dbPath := filepath.Join(geoPath, geoip.DatabaseName) 99 | if err != nil { 100 | logger.Println(err) 101 | } else if err := syscall.Access(dbPath, syscall.F_OK); err != nil { 102 | logger.Println("未下载GeoIP数据库") 103 | sw.getGeoButton = widgets.NewQPushButton2("下载GeoIP数据库", nil) 104 | sw.getGeoButton.ConnectClicked(sw.downloadGeoIPDatabase) 105 | buttonLayout.AddWidget(sw.getGeoButton, 0, core.Qt__AlignRight) 106 | } else if geoip.IsGeoIPOutdated(24 * time.Hour * 30) { 107 | // GeoIP数据有效期为30天,超过后需要更新 108 | logger.Println("GeoIP数据库需要更新") 109 | sw.getGeoButton = widgets.NewQPushButton2("更新GeoIP数据库", nil) 110 | sw.getGeoButton.ConnectClicked(sw.downloadGeoIPDatabase) 111 | buttonLayout.AddWidget(sw.getGeoButton, 0, core.Qt__AlignRight) 112 | } 113 | buttonLayout.AddWidget(updateButton, 0, core.Qt__AlignRight) 114 | leftLayout.AddLayout(buttonLayout, 0) 115 | 116 | rightLayout := widgets.NewQVBoxLayout() 117 | rightLayout.AddWidget(sw.switchPanel, 0, 0) 118 | rightLayout.AddWidget(sw.usedPanel, 0, 0) 119 | 120 | mainLayout := widgets.NewQHBoxLayout() 121 | mainLayout.AddLayout(leftLayout, 0) 122 | mainLayout.AddLayout(rightLayout, 0) 123 | sw.SetLayout(mainLayout) 124 | } 125 | 126 | // dataRefresh 处理数据更新 127 | // 一般在SetService之后调用,直接调用将更新servicePanel以外的数据 128 | func (sw *SummarizedWidget) dataRefresh() { 129 | // sw.service已经被外部更新 130 | ssrInfo := sw.dataBridge.SSRInfos(sw.service) 131 | sw.servicePanel.UpadteInfo(sw.user, ssrInfo) 132 | sw.invoicePanel.UpdateInvoices(sw.dataBridge.Invoices()) 133 | sw.switchPanel.DataRefresh(sw.conf, ssrInfo.Nodes) 134 | sw.usedPanel.DataRefresh(ssrInfo) 135 | ShowNotification("数据更新", "数据更新成功", "", -1) 136 | } 137 | 138 | // SetService 重新设置service,可用于更新数据 139 | // 调用后一般需要出发DataRefresh信号 140 | func (sw *SummarizedWidget) SetService(service *parser.Service) { 141 | sw.service = service 142 | } 143 | 144 | // UpdateConfig 当config更新时刷新switchPanel 145 | // 一般用作ConfigWidget的ConfigChanged信号处理函数 146 | func (sw *SummarizedWidget) UpdateConfig(conf *config.UserConfig) { 147 | sw.conf = conf 148 | nodes := sw.dataBridge.SSRInfos(sw.service).Nodes 149 | sw.switchPanel.DataRefresh(sw.conf, nodes) 150 | ShowNotification("配置更新", "配置更新成功", "", -1) 151 | } 152 | 153 | // 下载GeoIP数据库的回调函数 154 | func (sw *SummarizedWidget) downloadGeoIPDatabase(_ bool) { 155 | geoPath, err := geoip.GetGeoIPSavePath() 156 | if err != nil { 157 | info := fmt.Sprintf("GetGeoIPSavePath error: %v", err) 158 | showErrorDialog(info, sw) 159 | return 160 | } 161 | 162 | if err := syscall.Access(geoPath, syscall.F_OK); err != nil { 163 | err = os.MkdirAll(geoPath, 0775) 164 | if err != nil { 165 | info := fmt.Sprintf("make download dir: %s error: %v", geoPath, err) 166 | showErrorDialog(info, sw) 167 | return 168 | } 169 | } 170 | savePath := filepath.Join(geoPath, "GeoLite2-City.mmdb.gz") 171 | 172 | downloader, err := NewHTTPDownloader2(geoip.DownloadPath, "", "", nil) 173 | if err != nil { 174 | info := fmt.Sprintf("downloader error: %v", err) 175 | showErrorDialog(info, sw) 176 | return 177 | } 178 | downloader.SetParent(sw) 179 | 180 | progressDialog := getProgressDialog("下载地理信息", "GeoIP Database下载进度:", sw) 181 | progressDialog.ConnectCanceled(func() { 182 | downloader.Stop() 183 | progressDialog.Cancel() 184 | showErrorDialog("下载已取消", sw) 185 | }) 186 | downloader.ConnectUpdateProgress(func(size int) { 187 | if progressDialog.WasCanceled() { 188 | return 189 | } 190 | 191 | progressDialog.SetValue(size) 192 | }) 193 | downloader.ConnectUpdateMax(progressDialog.SetMaximum) 194 | downloader.ConnectFailed(func(err error) { 195 | progressDialog.Cancel() 196 | info := fmt.Sprintf("下载发生错误: %v", err) 197 | showErrorDialog(info, sw) 198 | }) 199 | downloader.ConnectDone(func() { 200 | progressDialog.Cancel() 201 | 202 | // 解压缩数据库 203 | f, err := os.Open(savePath) 204 | if err != nil { 205 | showErrorDialog(err.Error(), sw) 206 | return 207 | } 208 | defer f.Close() 209 | greader, err := gzip.NewReader(f) 210 | if err != nil { 211 | showErrorDialog(err.Error(), sw) 212 | return 213 | } 214 | defer greader.Close() 215 | 216 | dbPath := filepath.Join(geoPath, geoip.DatabaseName) 217 | dbFile, err := os.OpenFile(dbPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 218 | if err != nil { 219 | showErrorDialog(err.Error(), sw) 220 | return 221 | } 222 | defer dbFile.Close() 223 | 224 | buf, err := ioutil.ReadAll(greader) 225 | if err != nil { 226 | showErrorDialog(err.Error(), sw) 227 | return 228 | } 229 | _, err = dbFile.Write(buf) 230 | if err != nil { 231 | showErrorDialog(err.Error(), sw) 232 | return 233 | } 234 | 235 | ShowNotification("下载", "地理信息数据下载完成", "", -1) 236 | os.Remove(savePath) 237 | sw.getGeoButton.Hide() 238 | // 更新switch面板的GeoIP信息 239 | ssrInfo := sw.dataBridge.SSRInfos(sw.service) 240 | sw.switchPanel.DataRefresh(sw.conf, ssrInfo.Nodes) 241 | sw.dataBridge.GetLogger().Println("GeoIP Database下载成功") 242 | }) 243 | 244 | go downloader.Download(savePath) 245 | progressDialog.Exec() 246 | } 247 | -------------------------------------------------------------------------------- /widgets/switch_button.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "math" 5 | 6 | "github.com/therecipe/qt/core" 7 | "github.com/therecipe/qt/gui" 8 | "github.com/therecipe/qt/widgets" 9 | ) 10 | 11 | // 开关按钮,带有动画过度效果 12 | type SwitchButton struct { 13 | widgets.QWidget 14 | 15 | _ func(bool) `signal:"clicked"` 16 | 17 | // 动画的起始和结束值 18 | startValue float64 19 | endValue float64 20 | animation *core.QVariantAnimation 21 | 22 | checked bool 23 | 24 | uncheckedColor *gui.QColor 25 | checkedColor *gui.QColor 26 | indicatorColor *gui.QColor 27 | } 28 | 29 | // 生成一个带有默认状态的SwitchButton 30 | func NewSwitchButton2(checked bool) *SwitchButton { 31 | button := NewSwitchButton(nil, 0) 32 | button.checked = checked 33 | button.InitUI() 34 | 35 | return button 36 | } 37 | 38 | func (s *SwitchButton) InitUI() { 39 | s.startValue = 0.0 40 | s.endValue = 1.0 41 | s.animation = core.NewQVariantAnimation(s) 42 | s.animation.ConnectValueChanged(func(_ *core.QVariant) { 43 | s.Update() 44 | }) 45 | 46 | s.checkedColor = gui.NewQColor3(0x00, 0xee, 0x00, 255) 47 | s.uncheckedColor = gui.NewQColor3(0xee, 0xe9, 0xe9, 255) 48 | s.indicatorColor = gui.NewQColor2(core.Qt__white) 49 | 50 | sizePolicy := s.SizePolicy() 51 | sizePolicy.SetVerticalPolicy(widgets.QSizePolicy__Fixed) 52 | sizePolicy.SetHorizontalPolicy(widgets.QSizePolicy__Fixed) 53 | s.SetSizePolicy(sizePolicy) 54 | s.ConnectSizeHint(func() *core.QSize { 55 | return core.NewQSize2(60, 30) 56 | }) 57 | 58 | s.ConnectPaintEvent(s.paintEvent) 59 | // 模拟按钮点击信号 60 | s.ConnectMousePressEvent(func(event *gui.QMouseEvent) { 61 | if event.Button() == core.Qt__LeftButton { 62 | s.SetChecked(!s.IsChecked()) 63 | // 触发clicked信号,checked已经更新 64 | s.Clicked(s.IsChecked()) 65 | event.Accept() 66 | return 67 | } 68 | 69 | s.QWidget.MousePressEventDefault(event) 70 | }) 71 | } 72 | 73 | // 设置SwitchButton的点击状态并启动动画效果 74 | func (s *SwitchButton) SetChecked(checked bool) { 75 | if s.IsChecked() == checked { 76 | return 77 | } 78 | 79 | s.checked = checked 80 | // checked改变为true,则按钮动画从左向右移动;改变为false则从右向左移动 81 | if checked { 82 | s.animation.SetStartValue(core.NewQVariant12(s.startValue)) 83 | s.animation.SetEndValue(core.NewQVariant12(s.endValue)) 84 | } else { 85 | s.animation.SetStartValue(core.NewQVariant12(s.endValue)) 86 | s.animation.SetEndValue(core.NewQVariant12(s.startValue)) 87 | } 88 | 89 | s.animation.Start(core.QAbstractAnimation__KeepWhenStopped) 90 | } 91 | 92 | func (s *SwitchButton) paintEvent(_ *gui.QPaintEvent) { 93 | painter := gui.NewQPainter2(s) 94 | painter.SetRenderHints(gui.QPainter__Antialiasing|gui.QPainter__SmoothPixmapTransform, true) 95 | var background *gui.QColor 96 | if s.IsChecked() { 97 | background = s.checkedColor 98 | } else { 99 | background = s.uncheckedColor 100 | } 101 | 102 | border := 1.0 103 | heightF := float64(s.Height()) 104 | widthF := float64(s.Width()) 105 | 106 | backgroundPath := gui.NewQPainterPath() 107 | // 背景色条区域,起点:x为0+边框宽度,y为高度减去上下边框后的上部1/4处 108 | // 宽为宽度减去左右边框,高为高度减去上下边框后的1/2 109 | backgroundRect := core.NewQRectF4(border, 110 | (heightF-2*border)/4+border, 111 | widthF-2*border, 112 | (heightF-2*border)/2, 113 | ) 114 | backgroundPath.AddRoundedRect(backgroundRect, 115 | (heightF-2*border)/4, 116 | (heightF-2*border)/4, 117 | core.Qt__AbsoluteSize, 118 | ) 119 | backgroundPath.CloseSubpath() 120 | 121 | // 获得当前动画产生的移动距离的比例 122 | var moveRatio float64 123 | if s.animation.State() == core.QAbstractAnimation__Stopped { 124 | if !s.IsChecked() { 125 | moveRatio = s.startValue 126 | } else { 127 | moveRatio = s.endValue 128 | } 129 | } else { 130 | valid := false // 无实际用途,只是为了正常调用ToDouble 131 | moveRatio = s.animation.CurrentValue().ToDouble(&valid) 132 | } 133 | 134 | // 按钮可移动距离,宽减去高(按钮大小和高一致,不包括边框) 135 | moveWidth := (widthF - 2*border) - (heightF - 2*border) 136 | indicatorSize := heightF - 2*border 137 | indicatorPath := gui.NewQPainterPath() 138 | // 按钮绘制的起点为边框+可移动距离*当前需要移动的比例 139 | indicatorRect := core.NewQRectF4(moveWidth*moveRatio+border, 140 | border, 141 | indicatorSize, 142 | indicatorSize, 143 | ) 144 | indicatorPath.AddEllipse(indicatorRect) 145 | indicatorPath.CloseSubpath() 146 | 147 | indicatorCenterPath := gui.NewQPainterPath() 148 | // 位于按钮正中的小圆点宽高均为按钮1/2也就是整体控件高度的1/4 149 | // 为了突出白色按钮的显示效果而添加,颜色与背景色相同 150 | indicatorCenterRect := core.NewQRectF4( 151 | moveWidth*moveRatio+heightF*3.0/8, 152 | heightF*3/8, 153 | heightF/4, 154 | heightF/4, 155 | ) 156 | indicatorCenterPath.AddEllipse(indicatorCenterRect) 157 | indicatorCenterPath.CloseSubpath() 158 | 159 | // 位于背景色条的宽度25%/75%处的白色分隔符 160 | // checked为false时位于宽度75%处 161 | // 上下距离背景色条的距离均为3/8的height,高度为height的1/4暨背景色条的1/2 162 | sepPath := gui.NewQPainterPath() 163 | sepRect := core.NewQRectF4(widthF*math.Abs(moveRatio-3.0/4), 164 | heightF*3/8, 165 | widthF/20, 166 | heightF/4, 167 | ) 168 | sepPath.AddRect(sepRect) 169 | sepPath.CloseSubpath() 170 | 171 | // 背景色条的边框 172 | backgroundBorderRect := core.NewQRectF4(0, 173 | heightF/4, 174 | widthF, 175 | heightF/2, 176 | ) 177 | painter.SetPen2(gui.NewQColor2(core.Qt__lightGray)) 178 | painter.DrawRoundedRect(backgroundBorderRect, 179 | heightF/4, 180 | heightF/4, 181 | core.Qt__AbsoluteSize, 182 | ) 183 | 184 | painter.FillPath(backgroundPath, gui.NewQBrush3(background, core.Qt__SolidPattern)) 185 | painter.FillPath(sepPath, gui.NewQBrush3(s.indicatorColor, core.Qt__SolidPattern)) 186 | painter.FillPath(indicatorPath, gui.NewQBrush3(s.indicatorColor, core.Qt__SolidPattern)) 187 | painter.FillPath(indicatorCenterPath, gui.NewQBrush3(background, core.Qt__SolidPattern)) 188 | 189 | // 按钮的边框 190 | indicatorBorderRect := core.NewQRectF4((widthF-heightF)*moveRatio, 191 | 0, 192 | heightF, 193 | heightF, 194 | ) 195 | painter.DrawEllipse(indicatorBorderRect) 196 | 197 | // golang没有析构函数且painter为分配在堆上的对象,需要手动释放 198 | painter.DestroyQPainter() 199 | } 200 | 201 | // 返回按钮是否处于打开状态 202 | func (s *SwitchButton) IsChecked() bool { 203 | return s.checked 204 | } 205 | -------------------------------------------------------------------------------- /widgets/transparent_button.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/therecipe/qt/core" 7 | "github.com/therecipe/qt/widgets" 8 | ) 9 | 10 | // TransparentButton 透明按钮,当鼠标移动到按钮上时才会显示内容,否则处于透明状态 11 | type TransparentButton struct { 12 | widgets.QPushButton 13 | 14 | _ func() `constructor:"init"` 15 | 16 | hoverStyle string 17 | // defaultStyle 按钮不处于hover状态时的样式 18 | defaultStyle string 19 | } 20 | 21 | func (tb *TransparentButton) init() { 22 | tb.SetAttribute(core.Qt__WA_StyledBackground, true) 23 | tb.defaultStyle = "QPushButton{color:rgba(0,0,0,0);background:rgba(0,0,0,0);border:0px solid rgba(0,0,0,0);}" 24 | sizePolicy := tb.SizePolicy() 25 | sizePolicy.SetHorizontalPolicy(widgets.QSizePolicy__Fixed) 26 | sizePolicy.SetVerticalPolicy(widgets.QSizePolicy__Fixed) 27 | tb.SetSizePolicy(sizePolicy) 28 | tb.SetStyleSheet(tb.defaultStyle) 29 | } 30 | 31 | // NewTransparentButtonWithStyle 创建带有hover style的透明按钮 32 | // style参数的格式详见SetHoverStyle 33 | func NewTransparentButtonWithStyle(hoverStyle string) *TransparentButton { 34 | button := NewTransparentButton(nil) 35 | button.SetHoverStyle(hoverStyle) 36 | 37 | return button 38 | } 39 | 40 | // SetHoverStyle 设置按钮处于hover状态时的qss 41 | // style的格式须为{attr1:value1;attr2:value2; ...} 42 | func (tb *TransparentButton) SetHoverStyle(style string) { 43 | if !strings.HasPrefix(style, "{") || !strings.HasSuffix(style, "}") { 44 | return 45 | } 46 | 47 | tb.hoverStyle = "QPushButton:hover" + style 48 | // setStyleSheet会覆盖所有的qss,所以拼接后添加 49 | tb.SetStyleSheet(tb.defaultStyle + tb.hoverStyle) 50 | } 51 | -------------------------------------------------------------------------------- /widgets/used_panel.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/astaxie/beego/orm" 8 | "github.com/therecipe/qt/widgets" 9 | 10 | "schannel-qt5/models" 11 | "schannel-qt5/parser" 12 | ) 13 | 14 | type UsedPanel struct { 15 | widgets.QWidget 16 | 17 | // 数据更新时触发的信号,更新展示的数据 18 | _ func(*parser.SSRInfo) `signal:"dataRefresh,auto"` 19 | 20 | usedBar *ProgressBar 21 | uploadBar *ProgressBar 22 | downloadBar *ProgressBar 23 | 24 | totalLabel *widgets.QLabel 25 | usedLabel *widgets.QLabel 26 | uploadLabel *widgets.QLabel 27 | downloadLabel *widgets.QLabel 28 | 29 | // 套餐数据量信息 30 | total int 31 | used int 32 | upload int 33 | download int 34 | 35 | // 登录用户名 36 | user string 37 | info *parser.SSRInfo 38 | 39 | logger *log.Logger 40 | } 41 | 42 | // NewUsedPanelWithInfo 创建使用进度widget 43 | func NewUsedPanelWithInfo(user string, info *parser.SSRInfo, logger *log.Logger) *UsedPanel { 44 | u := NewUsedPanel(nil, 0) 45 | u.user = user 46 | u.info = info 47 | u.logger = logger 48 | u.InitUI() 49 | 50 | return u 51 | } 52 | 53 | // InitUI 根据info初始化ui,并连接信号 54 | func (u *UsedPanel) InitUI() { 55 | // 将string信息转换成int 56 | u.setData() 57 | 58 | // 初始化groupbox 59 | group := widgets.NewQGroupBox2("流量使用情况", u) 60 | 61 | // 初始化labels 62 | u.totalLabel = widgets.NewQLabel(nil, 0) 63 | u.usedLabel = widgets.NewQLabel(nil, 0) 64 | u.uploadLabel = widgets.NewQLabel(nil, 0) 65 | u.downloadLabel = widgets.NewQLabel(nil, 0) 66 | u.setLabels() 67 | 68 | // 初始化progressbar 69 | u.usedBar = NewProgressBarWithMark(u.total, u.used, computeRatio(u.total)) 70 | u.uploadBar = NewProgressBarWithMark(u.total, u.upload, computeRatio(u.total)) 71 | u.downloadBar = NewProgressBarWithMark(u.total, u.download, computeRatio(u.total)) 72 | 73 | chartsDialogButton := widgets.NewQPushButton2("统计图", nil) 74 | chartsDialogButton.ConnectClicked(func(_ bool) { 75 | chartsDialog := NewChartsDialog2(u.user, u.info.Name, u.logger, u) 76 | chartsDialog.Show() 77 | }) 78 | 79 | // 布局管理 80 | vbox := widgets.NewQVBoxLayout() 81 | hbox := widgets.NewQHBoxLayout() 82 | hbox.AddWidget(u.totalLabel, 0, 0) 83 | hbox.AddStretch(0) 84 | hbox.AddWidget(chartsDialogButton, 0, 0) 85 | vbox.AddLayout(hbox, 0) 86 | vbox.AddSpacing(0) 87 | vbox.AddWidget(u.usedLabel, 0, 0) 88 | vbox.AddWidget(u.usedBar, 0, 0) 89 | vbox.AddSpacing(0) 90 | vbox.AddWidget(u.downloadLabel, 0, 0) 91 | vbox.AddWidget(u.downloadBar, 0, 0) 92 | vbox.AddSpacing(0) 93 | vbox.AddWidget(u.uploadLabel, 0, 0) 94 | vbox.AddWidget(u.uploadBar, 0, 0) 95 | 96 | group.SetLayout(vbox) 97 | mainLayout := widgets.NewQVBoxLayout() 98 | mainLayout.AddWidget(group, 0, 0) 99 | u.SetLayout(mainLayout) 100 | u.Show() 101 | } 102 | 103 | // saveUsedAmount 将使用量信息保存至数据库 104 | func (u *UsedPanel) saveUsedAmount() { 105 | db := orm.NewOrm() 106 | now := parser.GetCurrentDay() 107 | err := models.SetUsedAmount(db, u.user, u.info.Service, u.total, u.upload, u.download, now) 108 | if err != nil { 109 | u.logger.Fatalf("save used amound error: %v\n", err) 110 | } 111 | u.logger.Printf("saved used_amount success, 记录日期: %v\n", now) 112 | } 113 | 114 | // setData 将used转换成int并设置给widget,随后存入数据库 115 | func (u *UsedPanel) setData() { 116 | format := "[%s] convert error: %s" 117 | u.total = convertToKb(u.info.TotalData) 118 | if u.total == -1 { 119 | u.logger.Fatalf(format, "total", u.info.TotalData) 120 | } 121 | 122 | u.used = convertToKb(u.info.UsedData) 123 | if u.used == -1 { 124 | u.logger.Fatalf(format, "used", u.info.UsedData) 125 | } 126 | 127 | u.upload = convertToKb(u.info.Upload) 128 | if u.upload == -1 { 129 | u.logger.Fatalf(format, "upload", u.info.Upload) 130 | } 131 | 132 | u.download = convertToKb(u.info.Download) 133 | if u.download == -1 { 134 | u.logger.Fatalf(format, "download", u.info.Download) 135 | } 136 | 137 | u.saveUsedAmount() 138 | } 139 | 140 | func (u *UsedPanel) setLabels() { 141 | u.totalLabel.SetText(fmt.Sprintf("套餐总量:%v", u.info.TotalData)) 142 | u.usedLabel.SetText(fmt.Sprintf("已使用:%v", u.info.UsedData)) 143 | u.uploadLabel.SetText(fmt.Sprintf("已上传:%v", u.info.Upload)) 144 | u.downloadLabel.SetText(fmt.Sprintf("已下载:%v", u.info.Download)) 145 | } 146 | 147 | // dataRefresh 刷新数据显示 148 | func (u *UsedPanel) dataRefresh(info *parser.SSRInfo) { 149 | u.info = info 150 | u.setData() 151 | 152 | u.setLabels() 153 | // 更新progressbar 154 | u.usedBar.SetMaximum(u.total) 155 | u.usedBar.SetMark(computeRatio(u.total)) 156 | u.usedBar.SetValue(u.used) 157 | u.uploadBar.SetMaximum(u.total) 158 | u.uploadBar.SetMark(computeRatio(u.total)) 159 | u.uploadBar.SetValue(u.upload) 160 | u.downloadBar.SetMaximum(u.total) 161 | u.downloadBar.SetMark(computeRatio(u.total)) 162 | u.downloadBar.SetValue(u.download) 163 | } 164 | -------------------------------------------------------------------------------- /widgets/utils.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math" 7 | "os" 8 | "regexp" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | "schannel-qt5/config" 16 | "schannel-qt5/geoip" 17 | ) 18 | 19 | const ( 20 | // KB 流量单位,1kb 21 | KB = 1 22 | // MB 流量单位,1024kb 23 | MB = 1024 * KB 24 | // GB 1024mb 25 | GB = MB * 1024 26 | // HighRatio 流量使用量警告阀值 27 | HighRatio = 0.9 28 | ) 29 | 30 | var ( 31 | // Matcher 匹配数字和流量单位 32 | usedMatcher = regexp.MustCompile(`(.+)(GB|MB|KB)`) 33 | // 匹配linux kernel版本 A.B.C 34 | versionMatcher = regexp.MustCompile(`(\d+\.\d+\.\d+)`) 35 | ) 36 | 37 | // convertToKb 将string类型的流量数据转换成以kb为单位的int 38 | // data格式为 number[KB|MB|GB] 39 | func convertToKb(data string) int { 40 | tmp := usedMatcher.FindStringSubmatch(data) 41 | if tmp == nil { 42 | return -1 43 | } 44 | // tmp[0]为完整字符串,1为数字,2为容量单位 45 | number, err := strconv.ParseFloat(tmp[1], 64) 46 | if err != nil { 47 | return -1 48 | } 49 | 50 | var ratio int 51 | switch tmp[2] { 52 | case "GB": 53 | ratio = GB 54 | case "MB": 55 | ratio = MB 56 | case "KB": 57 | ratio = KB 58 | default: 59 | return -1 60 | } 61 | 62 | res := number * float64(ratio) 63 | return int(res) 64 | } 65 | 66 | // computeRatio 根据ratio计算阀值 67 | func computeRatio(total int) int { 68 | res := float64(total) * HighRatio 69 | return int(res) 70 | } 71 | 72 | // time2string 将日期转换为2006-01-02的格式 73 | func time2string(t time.Time) string { 74 | return t.Format("2006-01-02") 75 | } 76 | 77 | // kernelVersion 获取linux kernel version 78 | func kernelVersion() (string, error) { 79 | uname := syscall.Utsname{} 80 | if err := syscall.Uname(&uname); err != nil { 81 | return "", err 82 | } 83 | 84 | ver := arr2str(uname.Release) 85 | version := versionMatcher.FindStringSubmatch(ver)[1] 86 | 87 | return version, nil 88 | } 89 | 90 | // arr2str 将[65]int8转换成string 91 | func arr2str(data [65]int8) string { 92 | var buf [65]byte 93 | for i, b := range data { 94 | buf[i] = byte(b) 95 | } 96 | 97 | str := string(buf[:]) 98 | // 截断\0 99 | if i := strings.Index(str, "\x00"); i != -1 { 100 | str = str[:i] 101 | } 102 | return str 103 | } 104 | 105 | // fastOpenAble 检查是否支持特tcp-fast-open 106 | // linux kernel version > 3.7 107 | func fastOpenAble(version string) bool { 108 | // index 0是主要版本,1是发布版本,2是修复版本 109 | // 分别与a,b,c对应 110 | ver := strings.Split(version, ".") 111 | a, _ := strconv.Atoi(ver[0]) 112 | b, _ := strconv.Atoi(ver[1]) 113 | 114 | if a >= 3 { 115 | if a > 3 || (a == 3 && b >= 7) { 116 | return true 117 | } 118 | } 119 | 120 | return false 121 | } 122 | 123 | // 检查路径是否是绝对路径,且不是目录 124 | func checkPath(path string) error { 125 | jpath := config.JSONPath{Data: path} 126 | if _, err := jpath.AbsPath(); err != nil { 127 | return err 128 | } else if path[len(path)-1] == '/' { 129 | return errors.New("dir is not allowed") 130 | } 131 | 132 | return nil 133 | } 134 | 135 | // checkEmptyPath 检查路径是否为绝对路径且不为目录,允许空路径 136 | func checkEmptyPath(path string) error { 137 | if path == "" { 138 | return nil 139 | } 140 | 141 | return checkPath(path) 142 | } 143 | 144 | // getGeoName 根据节点IP获取对应地区信息 145 | func getGeoName(ip string) string { 146 | country, city, err := geoip.GetCountryCity(ip, "zh-CN") 147 | if err != nil { 148 | fmt.Fprintf(os.Stderr, "GeoIP error: %v\n", err) 149 | } 150 | 151 | // city和country有一个不为空就返回 152 | if country == "" && city == "" { 153 | return "Unknown" 154 | } else if country == "" { 155 | return city 156 | } else if city == "" { 157 | return country 158 | } 159 | 160 | return strings.Join([]string{country, city}, "-") 161 | } 162 | 163 | // computeSizeUnit 计算图表适用的size单位,为KB,MB或GB 164 | // 因为月底网站会清空上月使用数据,所以选择最大的作为计量单位选择的依据 165 | // 返回单位名称和相对KB的换算倍率 166 | func computeSizeUnit(dataSet []int) (int, string) { 167 | sort.Ints(dataSet) 168 | max := dataSet[len(dataSet)-1] 169 | // 判断单位 170 | if max/GB != 0 { 171 | return GB, "GB" 172 | } else if max/MB != 0 { 173 | return MB, "MB" 174 | } 175 | 176 | return KB, "KB" 177 | } 178 | 179 | // computeRange 计算坐标轴的range,四舍五入为1位小数后+/-tuning,使折线平滑 180 | func computeRange(dataSet []int, ratio int, unit string) (float64, float64) { 181 | var tuning float64 182 | switch unit { 183 | case "GB": 184 | tuning = 0.075 185 | case "MB": 186 | tuning = 0.5 187 | case "KB": 188 | tuning = 5 189 | } 190 | 191 | data := make([]float64, 0, len(dataSet)) 192 | for _, v := range dataSet { 193 | value := float64(v) / float64(ratio) 194 | data = append(data, value) 195 | } 196 | sort.Float64s(data) 197 | 198 | // 四舍五入,保留一位小数 199 | max := math.Trunc(data[len(data)-1]*10+0.5) / 10 200 | min := math.Trunc(data[0]*10+0.5) / 10 201 | 202 | return math.Max(min-tuning, 0), max + tuning 203 | } 204 | -------------------------------------------------------------------------------- /widgets/utils_test.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "testing" 5 | 6 | "math" 7 | ) 8 | 9 | func TestFastOpenAble(t *testing.T) { 10 | testData := []*struct { 11 | version string 12 | res bool 13 | }{ 14 | { 15 | version: "2.6.32.1", 16 | res: false, 17 | }, 18 | { 19 | version: "3.6.2", 20 | res: false, 21 | }, 22 | { 23 | version: "4.17.19", 24 | res: true, 25 | }, 26 | { 27 | version: "3.7.0", 28 | res: true, 29 | }, 30 | } 31 | 32 | for _, v := range testData { 33 | if able := fastOpenAble(v.version); able != v.res { 34 | format := "fastOpenAble error: %v\n\twant: %v\n\thave: %v\n" 35 | t.Errorf(format, v.version, v.res, able) 36 | } 37 | } 38 | } 39 | 40 | func TestConvertToKb(t *testing.T) { 41 | testData := []*struct { 42 | data string 43 | res int 44 | }{ 45 | { 46 | data: "10KB", 47 | res: 10, 48 | }, 49 | { 50 | data: "10MB", 51 | res: 1024 * 10, 52 | }, 53 | { 54 | data: "10GB", 55 | res: 1024 * 1024 * 10, 56 | }, 57 | { 58 | data: "10.41KB", 59 | // 10.41 * 1 60 | res: 10, 61 | }, 62 | { 63 | data: "10.41MB", 64 | // 10.41 * 1024 65 | res: 10659, 66 | }, 67 | { 68 | data: "10.41GB", 69 | // 10.41 * 1024 * 1024 70 | res: 10915676, 71 | }, 72 | { 73 | data: "", 74 | res: -1, 75 | }, 76 | { 77 | data: "test", 78 | res: -1, 79 | }, 80 | } 81 | 82 | for _, v := range testData { 83 | res := convertToKb(v.data) 84 | if res != v.res { 85 | format := "convertToKb error: %v\n\twant: %v\n\thave: %v\n" 86 | t.Errorf(format, v.data, v.res, res) 87 | } 88 | } 89 | } 90 | 91 | func TestComputeSizeUnit(t *testing.T) { 92 | testData := []*struct { 93 | data []int 94 | ratio int 95 | unit string 96 | }{ 97 | { 98 | data: []int{1, 10 * GB, 5 * MB, 1000 * MB, 100 * KB}, 99 | ratio: GB, 100 | unit: "GB", 101 | }, 102 | { 103 | data: []int{4000, 50, 546, 1}, 104 | ratio: MB, 105 | unit: "MB", 106 | }, 107 | { 108 | data: []int{0, 100 * KB, 56 * KB, 1000 * KB}, 109 | ratio: KB, 110 | unit: "KB", 111 | }, 112 | { 113 | data: []int{0}, 114 | ratio: KB, 115 | unit: "KB", 116 | }, 117 | } 118 | 119 | for _, v := range testData { 120 | ratio, unit := computeSizeUnit(v.data) 121 | if ratio != v.ratio || unit != v.unit { 122 | format := "computeSizeUnit error: %v\n\twant: (%v, %v)\n\thave: (%v, %v)\n" 123 | t.Errorf(format, v.data, v.ratio, v.unit, ratio, unit) 124 | } 125 | } 126 | } 127 | 128 | func TestComputeRange(t *testing.T) { 129 | testData := []*struct { 130 | data []int 131 | ratio int 132 | unit string 133 | min, max float64 134 | }{ 135 | { 136 | data: []int{1, 10, 2}, 137 | ratio: KB, 138 | unit: "KB", 139 | min: 0, 140 | max: 15.0, 141 | }, 142 | { 143 | data: []int{1900 * KB, 100 * MB}, 144 | ratio: MB, 145 | unit: "MB", 146 | min: 1.4, 147 | max: 100.5, 148 | }, 149 | { 150 | data: []int{1782580 * KB, 2590 * MB, 2 * GB}, 151 | ratio: GB, 152 | unit: "GB", 153 | min: 1.625, 154 | max: 2.575, 155 | }, 156 | } 157 | 158 | for _, v := range testData { 159 | min, max := computeRange(v.data, v.ratio, v.unit) 160 | if !floatEqual(min, v.min) { 161 | format := "range min wrong:\n\twant: %v\n\thave: %v\n" 162 | t.Errorf(format, v.min, min) 163 | } 164 | if !floatEqual(max, v.max) { 165 | format := "range max wrong:\n\twant: %v\n\thave: %v\n" 166 | t.Errorf(format, v.max, max) 167 | } 168 | } 169 | } 170 | 171 | func floatEqual(a, b float64) bool { 172 | EPSILON := 0.00000001 173 | return math.Abs(a-b) < EPSILON 174 | } 175 | -------------------------------------------------------------------------------- /widgets/widget_utils.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/therecipe/qt/core" 7 | "github.com/therecipe/qt/widgets" 8 | ) 9 | 10 | // showErrorMsg 控制error label的显示 11 | // err为nil则代表没有错误发生,如果label可见则设为隐藏 12 | // err不为nil时设置label可见 13 | // 设置label可见时返回true,否则返回false(不受label原有状态影响) 14 | func showErrorMsg(label *ColorLabel, err error) bool { 15 | if err != nil { 16 | label.Show() 17 | return true 18 | } 19 | 20 | label.Hide() 21 | return false 22 | } 23 | 24 | // showErrorDialog 显示错误信息 25 | func showErrorDialog(info string, parent widgets.QWidget_ITF) { 26 | errMsg := widgets.NewQErrorMessage(parent) 27 | errMsg.ShowMessage(info) 28 | errMsg.SetAttribute(core.Qt__WA_DeleteOnClose, true) 29 | shade := NewShadeWidget2(parent.QWidget_PTR().NativeParentWidget()) 30 | errMsg.Exec() 31 | shade.Close() 32 | } 33 | 34 | var ErrCanceled = errors.New("QFileDialog canceled") 35 | 36 | // getFileSavePath 使用QFileDialog获取文件保存路径 37 | // 默认使用上次保存的目录,否则使用$HOME 38 | func getFileSavePath(service, fileName, filter string, parent widgets.QWidget_ITF) (string, error) { 39 | defaultPath, err := defaultSavePath(service, fileName) 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | savePath := widgets.QFileDialog_GetSaveFileName(parent, 45 | "保存", 46 | defaultPath, 47 | filter, 48 | "", 49 | 0) 50 | if savePath == "" { 51 | return "", ErrCanceled 52 | } 53 | 54 | if err := pathRecorder.SetLastSavePath(service, savePath); err != nil { 55 | return "", err 56 | } 57 | 58 | return savePath, nil 59 | } 60 | 61 | // GetProgressDialog 返回经过配置的QProgressDialog 62 | func getProgressDialog(title, label string, parent widgets.QWidget_ITF) *widgets.QProgressDialog { 63 | progressDialog := widgets.NewQProgressDialog(parent, 0) 64 | progressDialog.SetAttribute(core.Qt__WA_DeleteOnClose, true) 65 | progressDialog.SetCancelButtonText("取消") 66 | progressDialog.SetLabelText(label) 67 | progressDialog.SetRange(0, 0) 68 | progressDialog.SetAutoReset(false) 69 | progressDialog.SetWindowTitle(title) 70 | 71 | return progressDialog 72 | } 73 | --------------------------------------------------------------------------------