diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4f779bb..154fa90 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,7 +6,11 @@ "extensions": ["ms-vscode.go"] }, "settings": { - "go.useLanguageServer": true + "go.useLanguageServer": true, + "go.vetFlags": ["-unsafeptr=false"], + "gopls": { + "analyses": { "unsafeptr": false } + } } }, "postCreateCommand": "go mod download" diff --git a/cmd/splashscreen-changer/args.go b/cmd/splashscreen-changer/args.go new file mode 100644 index 0000000..71683f1 --- /dev/null +++ b/cmd/splashscreen-changer/args.go @@ -0,0 +1,103 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" +) + +// ヘルプメッセージを表示する関数 +func printHelp() { + fmt.Println("Usage: splashscreen-changer [options]") + fmt.Println("Options:") + flag.PrintDefaults() + fmt.Println("Environment Variables:") + fmt.Printf(" %-20s %s\n", "CONFIG_PATH", "Path to the configuration file (default: data/config.yaml)") + + // Config 構造体のフィールドから環境変数のキーを生成して表示 + configType := reflect.TypeOf(Config{}) + for i := 0; i < configType.NumField(); i++ { + section := configType.Field(i) + sectionType := section.Type + + for j := 0; j < sectionType.NumField(); j++ { + field := sectionType.Field(j) + helpTag := field.Tag.Get("help") + envKey := strings.ToUpper(section.Name + "_" + field.Name) + defaultValue := field.Tag.Get("default") + if defaultValue != "" { + helpTag += fmt.Sprintf(" (default: %s)", defaultValue) + } + fmt.Printf(" %-20s %s\n", envKey, helpTag) + } + } + + fmt.Println() + fmt.Println("GitHub: https://github.com/tomacheese/splashscreen-changer") +} + +func getSourcePath(config *Config) (string, error) { + // 取得の優先度は以下。 + // 1. 環境変数 SOURCE_PATH + // 2. 設定ファイル source.path + // 3. ユーザーフォルダの Pictures フォルダ内、VRChat フォルダ + // エラー。 + + // 1, 2 については、config.go にて実装済み。空値できた場合のみ、3 を行う + // 3 については、ユーザーフォルダの Pictures フォルダ内に VRChat フォルダが存在するか確認し、存在する場合はそのパスを返す + if config.Source.Path != "" { + return config.Source.Path, nil + } + + errorRequired := fmt.Errorf("source.path is required") + + // ユーザーフォルダの Pictures フォルダ内に VRChat フォルダが存在するか確認 + picturesLegacyPath, errLegacy := getPicturesLegacyPath() + picturesNewPath, err := getPicturesPath() + if errLegacy != nil && err != nil { + return "", errorRequired + } + + picturesPath := picturesLegacyPath + if errLegacy != nil { + picturesPath = picturesNewPath + } + + vrchatPath := filepath.Join(picturesPath, "VRChat") + if _, err := os.Stat(vrchatPath); err == nil { + return vrchatPath, errorRequired + } + + return "", errorRequired +} + +func getDestinationPath(config *Config) (string, error) { + // 取得の優先度は以下。 + // 1. 環境変数 DESTINATION_PATH + // 2. 設定ファイル destination.path + // 3. Steam ライブラリフォルダから、VRChat のインストール先を取得 + // エラー。 + + // 1, 2 については、config.go にて実装済み。空値できた場合のみ、3 を行う + // 3 については、Steam ライブラリフォルダから、VRChat のインストール先フォルダを返す(EasyAntiCheatフォルダがあることを確認する)。見つからない場合はエラーを返す + if config.Destination.Path != "" { + return config.Destination.Path, nil + } + + errorRequired := fmt.Errorf("destination.path is required") + + vrchatPath, err := findSteamGameDirectory("VRChat") + if err != nil { + return vrchatPath, nil + } + + _, err = os.Stat(filepath.Join(vrchatPath, "EasyAntiCheat")) + if err != nil { + return "", fmt.Errorf("EasyAntiCheat folder not found in %s", vrchatPath) + } + + return "", errorRequired +} diff --git a/cmd/splashscreen-changer/config.go b/cmd/splashscreen-changer/config.go index 612d659..7ac47b8 100644 --- a/cmd/splashscreen-changer/config.go +++ b/cmd/splashscreen-changer/config.go @@ -12,11 +12,11 @@ import ( type Config struct { Source struct { - Path string `yaml:"path" help:"Path to the source directory" required:"true"` - Recursive bool `yaml:"recursive" help:"Whether to search for PNG files recursively. Default is false" default:"false"` + Path string `yaml:"path" help:"Path to the source directory. If not specified, the VRChat folder in the user's Pictures folder is searched and used if available. If not, an error is returned."` + Recursive bool `yaml:"recursive" help:"Whether to search for PNG files recursively" default:"true"` } `yaml:"source" required:"true"` Destination struct { - Path string `yaml:"path" help:"Path to the destination directory. The specified directory must have an EasyAntiCheat directory" required:"true"` + Path string `yaml:"path" help:"Path to the destination directory. The specified directory must have an EasyAntiCheat directory. If not specified, the VRChat folder is searched based on the Steam library folder and used if available. If not, an error is returned."` Width int `yaml:"width" help:"Width of the destination image" default:"800"` Height int `yaml:"height" help:"Height of the destination image" default:"450"` } `yaml:"destination" required:"true"` @@ -163,17 +163,22 @@ func checkConfig(config *Config) error { } // パスが存在するかチェック - if _, err := os.Stat(config.Source.Path); err != nil { - return fmt.Errorf("source path '%s' does not exist", config.Source.Path) - } - if _, err := os.Stat(config.Destination.Path); err != nil { - return fmt.Errorf("destination path '%s' does not exist", config.Destination.Path) + if config.Source.Path != "" { + if _, err := os.Stat(config.Source.Path); err != nil { + return fmt.Errorf("source path '%s' does not exist", config.Source.Path) + } } - // destination.path には "EasyAntiCheat" ディレクトリが存在すること - eacPath := config.Destination.Path + "/EasyAntiCheat" - if _, err := os.Stat(eacPath); err != nil { - return fmt.Errorf("EasyAntiCheat directory not found in destination path '%s'", config.Destination.Path) + if config.Destination.Path != "" { + if _, err := os.Stat(config.Destination.Path); err != nil { + return fmt.Errorf("destination path '%s' does not exist", config.Destination.Path) + } + + // destination.path には "EasyAntiCheat" ディレクトリが存在すること + eacPath := config.Destination.Path + "/EasyAntiCheat" + if _, err := os.Stat(eacPath); err != nil { + return fmt.Errorf("EasyAntiCheat directory not found in destination path '%s'", config.Destination.Path) + } } // destination.width が 0 より大きいこと diff --git a/cmd/splashscreen-changer/config_test.go b/cmd/splashscreen-changer/config_test.go index 5bf5ced..b41f197 100644 --- a/cmd/splashscreen-changer/config_test.go +++ b/cmd/splashscreen-changer/config_test.go @@ -126,8 +126,8 @@ destination: if config.Source.Path != filepath.Join(tmpDir, "source") { t.Errorf("Expected source path to be '%s', got '%s'", filepath.Join(tmpDir, "source"), config.Source.Path) } - if config.Source.Recursive { - t.Errorf("Expected source recursive to be false, got true") + if !config.Source.Recursive { + t.Errorf("Expected source recursive to be true got false") } if config.Destination.Path != filepath.Join(tmpDir, "destination") { t.Errorf("Expected destination path to be '%s', got '%s'", filepath.Join(tmpDir, "destination"), config.Destination.Path) diff --git a/cmd/splashscreen-changer/main.go b/cmd/splashscreen-changer/main.go index 4cb42f9..669020e 100644 --- a/cmd/splashscreen-changer/main.go +++ b/cmd/splashscreen-changer/main.go @@ -7,7 +7,6 @@ import ( "image/png" "os" "path/filepath" - "reflect" "strings" "time" @@ -132,32 +131,6 @@ func resizePNGFile(srcPath, destPath string, width, height int) error { return nil } -// ヘルプメッセージを表示する関数 -func printHelp() { - fmt.Println("Usage: splashscreen-changer [options]") - fmt.Println("Options:") - flag.PrintDefaults() - fmt.Println("Environment Variables:") - fmt.Printf(" %-20s %s\n", "CONFIG_PATH", "Path to the configuration file (default: data/config.yaml)") - - // Config 構造体のフィールドから環境変数のキーを生成して表示 - configType := reflect.TypeOf(Config{}) - for i := 0; i < configType.NumField(); i++ { - section := configType.Field(i) - sectionType := section.Type - - for j := 0; j < sectionType.NumField(); j++ { - field := sectionType.Field(j) - helpTag := field.Tag.Get("help") - envKey := strings.ToUpper(section.Name + "_" + field.Name) - fmt.Printf(" %-20s %s\n", envKey, helpTag) - } - } - - fmt.Println() - fmt.Println("GitHub: https://github.com/tomacheese/splashscreen-changer") -} - func main() { // コマンドライン引数を解析する helpFlag := flag.Bool("help", false, "Show help message") @@ -186,19 +159,43 @@ func main() { fmt.Println("Loading config file:", *configPath) config, err := LoadConfig(*configPath) if err != nil { - fmt.Println("Error:", err) + fmt.Println("Failed to load configuration file:", err) + return + } + + sourcePath, err := getSourcePath(config) + if err != nil { + fmt.Println("Failed to obtain source path") + fmt.Println() + fmt.Println("The following steps are used to obtain the source paths. This error occurs because the following steps could not be taken to obtain the source path.") + fmt.Println("1. Environment variable SOURCE_PATH. If this is not set, the following steps are taken.") + fmt.Println("2. source.path in Configuration file. If this is not set, the following steps are taken.") + fmt.Println("3. Check if the VRChat folder exists in the Pictures folder in the user folder.") + fmt.Println("If the VRChat folder exists, the path to the VRChat folder is used as the source path.") + return + } + + destinationPath, err := getDestinationPath(config) + if err != nil { + fmt.Println("Failed to obtain destination path") + fmt.Println() + fmt.Println("The following steps are used to obtain the destination paths. This error occurs because the following steps could not be taken to obtain the destination path.") + fmt.Println("1. Environment variable DESTINATION_PATH. If this is not set, the following steps are taken.") + fmt.Println("2. destination.path in Configuration file. If this is not set, the following steps are taken.") + fmt.Println("3. Get the installation destination folder of VRChat from the Steam library folder.") + fmt.Println("If the EasyAntiCheat folder exists in the VRChat folder, the path to the VRChat folder is used as the destination path.") return } // 設定値を表示する - fmt.Printf("Source Path: %s\n", config.Source.Path) + fmt.Printf("Source Path: %s\n", sourcePath) fmt.Printf("Source Recursive: %t\n", config.Source.Recursive) - fmt.Printf("Destination Path: %s\n", config.Destination.Path) + fmt.Printf("Destination Path: %s\n", destinationPath) fmt.Printf("Destination Width: %d\n", config.Destination.Width) fmt.Printf("Destination Height: %d\n", config.Destination.Height) // ソースディレクトリ以下のPNGファイルをリストする - files, err := listPNGFiles(config.Source.Path, config.Source.Recursive) + files, err := listPNGFiles(sourcePath, config.Source.Recursive) if err != nil { fmt.Println("Error:", err) return @@ -219,7 +216,7 @@ func main() { fmt.Println("Picked file:", pickedFile) // ファイルをリサイズして EasyAntiCheat ディレクトリに保存する - destFile := filepath.Join(config.Destination.Path, "EasyAntiCheat", "SplashScreen.png") + destFile := filepath.Join(destinationPath, "EasyAntiCheat", "SplashScreen.png") err = resizePNGFile(pickedFile, destFile, config.Destination.Width, config.Destination.Height) if err != nil { fmt.Println("Error:", err) diff --git a/cmd/splashscreen-changer/specialfolder_other.go b/cmd/splashscreen-changer/specialfolder_other.go new file mode 100644 index 0000000..1418c88 --- /dev/null +++ b/cmd/splashscreen-changer/specialfolder_other.go @@ -0,0 +1,17 @@ +//go:build !windows +// +build !windows + +package main + +import ( + "fmt" + "runtime" +) + +func getPicturesLegacyPath() (string, error) { + return "", fmt.Errorf("unsupported OS: %s", runtime.GOOS) +} + +func getPicturesPath() (string, error) { + return "", fmt.Errorf("unsupported OS: %s", runtime.GOOS) +} diff --git a/cmd/splashscreen-changer/specialfolder_windows.go b/cmd/splashscreen-changer/specialfolder_windows.go new file mode 100644 index 0000000..8754807 --- /dev/null +++ b/cmd/splashscreen-changer/specialfolder_windows.go @@ -0,0 +1,47 @@ +//go:build windows +// +build windows + +package main + +import ( + "fmt" + "syscall" + "unsafe" +) + +// SHGetKnownFolderPathを使うための設定 +var ( + modShell32 = syscall.NewLazyDLL("shell32.dll") + procSHGetKnownFolderPath = modShell32.NewProc("SHGetKnownFolderPath") + modOle32 = syscall.NewLazyDLL("ole32.dll") + procCoTaskMemFree = modOle32.NewProc("CoTaskMemFree") + FOLDERID_PicturesLegacy = syscall.GUID{Data1: 0x0DDD015D, Data2: 0xB06C, Data3: 0x45D5, Data4: [8]byte{0x8C, 0x4C, 0xF5, 0x97, 0x13, 0x85, 0x46, 0x39}} + FOLDERID_Pictures = syscall.GUID{Data1: 0x33E28130, Data2: 0x4E1E, Data3: 0x4676, Data4: [8]byte{0x83, 0x5A, 0x98, 0x5A, 0x76, 0x87, 0x67, 0x4D}} +) + +// getKnownFolderPath は、指定されたKnown Folderのパスを取得します。 +func getKnownFolderPath(folderID *syscall.GUID) (string, error) { + var pathPtr uintptr + // SHGetKnownFolderPathを呼び出してフォルダパスを取得 + ret, _, _ := procSHGetKnownFolderPath.Call( + uintptr(unsafe.Pointer(folderID)), + 0, + 0, + uintptr(unsafe.Pointer(&pathPtr)), + ) + if ret != 0 { + return "", fmt.Errorf("failed to get folder path, error code: %d", ret) + } + defer procCoTaskMemFree.Call(pathPtr) // メモリ解放 + + // ポインタから文字列を取得 + return syscall.UTF16ToString((*[1 << 16]uint16)(unsafe.Pointer(pathPtr))[:]), nil +} + +func getPicturesLegacyPath() (string, error) { + return getKnownFolderPath(&FOLDERID_PicturesLegacy) +} + +func getPicturesPath() (string, error) { + return getKnownFolderPath(&FOLDERID_Pictures) +} diff --git a/cmd/splashscreen-changer/steam_other.go b/cmd/splashscreen-changer/steam_other.go new file mode 100644 index 0000000..85a3ed8 --- /dev/null +++ b/cmd/splashscreen-changer/steam_other.go @@ -0,0 +1,21 @@ +//go:build !windows +// +build !windows + +package main + +import ( + "fmt" + "runtime" +) + +func GetSteamInstallFolder() (string, error) { + return "", fmt.Errorf("unsupported OS: %s", runtime.GOOS) +} + +func getSteamLibraryFolders(_ string) ([]string, error) { + return nil, fmt.Errorf("unsupported OS: %s", runtime.GOOS) +} + +func findSteamGameDirectory(_ string) (string, error) { + return "", fmt.Errorf("unsupported OS: %s", runtime.GOOS) +} diff --git a/cmd/splashscreen-changer/steam_windows.go b/cmd/splashscreen-changer/steam_windows.go new file mode 100644 index 0000000..d4e9083 --- /dev/null +++ b/cmd/splashscreen-changer/steam_windows.go @@ -0,0 +1,108 @@ +//go:build windows +// +build windows + +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/andygrunwald/vdf" + "golang.org/x/sys/windows/registry" +) + +func GetSteamInstallFolder() (string, error) { + // Open the key for reading + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Wow6432Node\Valve\Steam`, registry.QUERY_VALUE) + if err != nil { + return "", err + } + defer k.Close() + + // Read the value of the key + installPath, _, err := k.GetStringValue("InstallPath") + if err != nil { + return "", err + } + + return installPath, nil +} + +func getSteamLibraryFolders(steamInstallPath string) ([]string, error) { + // Open the file for reading + steamLibraryFoldersPath := filepath.Join(steamInstallPath, "steamapps", "libraryfolders.vdf") + f, err := os.Open(steamLibraryFoldersPath) + if err != nil { + return nil, err + } + defer f.Close() + + // Parse the VDF file + p := vdf.NewParser(f) + vdf, err := p.Parse() + if err != nil { + return nil, err + } + + // Get the LibraryFolders section + libraryFolders, ok := vdf["libraryfolders"] + if !ok { + return nil, fmt.Errorf("LibraryFolders not found in %s", steamLibraryFoldersPath) + } + + // Iterate over the LibraryFolders and get the paths + paths := []string{} + for key, value := range libraryFolders.(map[string]any) { + // The first path is the Steam installation folder + if key == "0" { + paths = append(paths, steamInstallPath) + continue + } + + // Get the path + path, ok := value.(map[string]interface{})["path"] + if !ok { + return nil, fmt.Errorf("path not found in LibraryFolders[%s]", key) + } + + // Convert the path to a string + pathStr, ok := path.(string) + if !ok { + return nil, fmt.Errorf("path is not a string in LibraryFolders[%s]", key) + } + + // Append the path to the list of paths + paths = append(paths, pathStr) + } + + // Return the list of paths + return paths, nil +} + +func findSteamGameDirectory(gameName string) (string, error) { + steamInstallPath, err := GetSteamInstallFolder() + if err != nil { + return "", err + } + + steamLibraryFolders, err := getSteamLibraryFolders(steamInstallPath) + if err != nil { + return "", err + } + + // Iterate over the Steam Library Folders + for _, steamLibraryFolder := range steamLibraryFolders { + // The Steam Library Folder contains the game directory + // e.g. C:\Program Files (x86)\Steam\steamapps\common\Portal 2 + gameDirectory := filepath.Join(steamLibraryFolder, "steamapps", "common", gameName) + + // Check if the game directory exists + if _, err := os.Stat(gameDirectory); err == nil { + return gameDirectory, nil + } + } + + // Return an error if the game directory was not found + return "", fmt.Errorf("game directory not found for %s", gameName) +} diff --git a/go.mod b/go.mod index feb0e35..7dcdad0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/tomacheese/splashscreen-changer go 1.23.2 require ( + github.com/andygrunwald/vdf v1.1.0 golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f golang.org/x/image v0.22.0 + golang.org/x/sys v0.27.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 7378fac..ffecd2c 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,23 @@ +github.com/andygrunwald/vdf v1.1.0 h1:gmstp0R7DOepIZvWoSJY97ix7QOrsxpGPU6KusKXqvw= +github.com/andygrunwald/vdf v1.1.0/go.mod h1:f31AAs7HOKvs5B167iwLHwKuqKc4bE46Vdt7xQogA0o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=