forked from joejulian/gio
cmd/gogio: add support for Windows
Now, gogio can build the program for Windows, using the `-target windows`. It will build with `-H=windowsgui`, by default. Also, it can compile for multiple platforms if specified using `-target` (e.g. `-target arm, 386, amd64`), the executable will have the respective suffix (i.e. `_386.exe`). gogio will also attach (any) appicon.png as executable icon resource and include some information about the file and supported operating system. Signed-off-by: Inkeliz <inkeliz@inkeliz.com>
This commit is contained in:
@@ -97,7 +97,6 @@ func buildAndroid(tmpDir string, bi *buildInfo) error {
|
||||
buildtools: buildtools,
|
||||
androidjar: filepath.Join(platform, "android.jar"),
|
||||
}
|
||||
|
||||
perms := []string{"default"}
|
||||
const permPref = "gioui.org/app/permission/"
|
||||
cfg := &packages.Config{
|
||||
@@ -176,11 +175,15 @@ func compileAndroid(tmpDir string, tools *androidTools, bi *buildInfo) (err erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
minSDK := 16
|
||||
if bi.minsdk > minSDK {
|
||||
minSDK = bi.minsdk
|
||||
}
|
||||
tcRoot := filepath.Join(ndkRoot, "toolchains", "llvm", "prebuilt", archNDK())
|
||||
var builds errgroup.Group
|
||||
for _, a := range bi.archs {
|
||||
arch := allArchs[a]
|
||||
clang, err := latestCompiler(tcRoot, a, bi.minsdk)
|
||||
clang, err := latestCompiler(tcRoot, a, minSDK)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s. Please make sure you have NDK >= r19c installed. Use the command `sdkmanager ndk-bundle` to install it.", err)
|
||||
}
|
||||
@@ -351,13 +354,9 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe
|
||||
return err
|
||||
}
|
||||
}
|
||||
icon := *iconPath
|
||||
if icon == "" {
|
||||
icon = filepath.Join(bi.pkgDir, "appicon.png")
|
||||
}
|
||||
iconSnip := ""
|
||||
if _, err := os.Stat(icon); err == nil {
|
||||
err := buildIcons(resDir, icon, []iconVariant{
|
||||
if _, err := os.Stat(bi.iconPath); err == nil {
|
||||
err := buildIcons(resDir, bi.iconPath, []iconVariant{
|
||||
{path: filepath.Join("mipmap-hdpi", "ic_launcher.png"), size: 72},
|
||||
{path: filepath.Join("mipmap-xhdpi", "ic_launcher.png"), size: 96},
|
||||
{path: filepath.Join("mipmap-xxhdpi", "ic_launcher.png"), size: 144},
|
||||
@@ -394,12 +393,16 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe
|
||||
if bi.minsdk > targetSDK {
|
||||
targetSDK = bi.minsdk
|
||||
}
|
||||
minSDK := 16
|
||||
if bi.minsdk > minSDK {
|
||||
minSDK = bi.minsdk
|
||||
}
|
||||
permissions, features := getPermissions(perms)
|
||||
appName := strings.Title(bi.name)
|
||||
manifestSrc := manifestData{
|
||||
AppID: bi.appID,
|
||||
Version: bi.version,
|
||||
MinSDK: bi.minsdk,
|
||||
MinSDK: minSDK,
|
||||
TargetSDK: targetSDK,
|
||||
Permissions: permissions,
|
||||
Features: features,
|
||||
|
||||
+35
-20
@@ -3,22 +3,26 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type buildInfo struct {
|
||||
appID string
|
||||
archs []string
|
||||
ldflags string
|
||||
minsdk int
|
||||
name string
|
||||
pkgDir string
|
||||
pkgPath string
|
||||
tags string
|
||||
target string
|
||||
version int
|
||||
appID string
|
||||
archs []string
|
||||
ldflags string
|
||||
minsdk int
|
||||
name string
|
||||
pkgDir string
|
||||
pkgPath string
|
||||
iconPath string
|
||||
tags string
|
||||
target string
|
||||
version int
|
||||
}
|
||||
|
||||
func newBuildInfo(pkgPath string) (*buildInfo, error) {
|
||||
@@ -27,17 +31,22 @@ func newBuildInfo(pkgPath string) (*buildInfo, error) {
|
||||
return nil, err
|
||||
}
|
||||
appID := getAppID(pkgMetadata)
|
||||
appIcon := filepath.Join(pkgMetadata.Dir, "appicon.png")
|
||||
if *iconPath != "" {
|
||||
appIcon = *iconPath
|
||||
}
|
||||
bi := &buildInfo{
|
||||
appID: appID,
|
||||
archs: getArchs(),
|
||||
ldflags: getLdFlags(appID),
|
||||
minsdk: *minsdk,
|
||||
name: getPkgName(pkgMetadata),
|
||||
pkgDir: pkgMetadata.Dir,
|
||||
pkgPath: pkgPath,
|
||||
tags: *extraTags,
|
||||
target: *target,
|
||||
version: *version,
|
||||
appID: appID,
|
||||
archs: getArchs(),
|
||||
ldflags: getLdFlags(appID),
|
||||
minsdk: *minsdk,
|
||||
name: getPkgName(pkgMetadata),
|
||||
pkgDir: pkgMetadata.Dir,
|
||||
pkgPath: pkgPath,
|
||||
iconPath: appIcon,
|
||||
tags: *extraTags,
|
||||
target: *target,
|
||||
version: *version,
|
||||
}
|
||||
return bi, nil
|
||||
}
|
||||
@@ -54,6 +63,12 @@ func getArchs() []string {
|
||||
return []string{"arm64", "amd64"}
|
||||
case "android":
|
||||
return []string{"arm", "arm64", "386", "amd64"}
|
||||
case "windows":
|
||||
goarch := os.Getenv("GOARCH")
|
||||
if goarch == "" {
|
||||
goarch = runtime.GOARCH
|
||||
}
|
||||
return []string{goarch}
|
||||
default:
|
||||
// TODO: Add flag tests.
|
||||
panic("The target value has already been validated, this will never execute.")
|
||||
|
||||
@@ -52,6 +52,9 @@ component of the 1.0.X version for iOS and tvOS.
|
||||
For Android builds the -minsdk flag specify the minimum SDK level. For example,
|
||||
use -minsdk 22 to target Android 5.1 (Lollipop) and later.
|
||||
|
||||
For Windows builds the -minsdk flag specify the minimum OS version. For example,
|
||||
use -mindk 10 to target Windows 10 only, -minsdk 6 for Windows Vista and later.
|
||||
|
||||
The -work flag prints the path to the working directory and suppress
|
||||
its deletion.
|
||||
|
||||
|
||||
@@ -222,12 +222,8 @@ int main(int argc, char * argv[]) {
|
||||
if err := ioutil.WriteFile(plistFile, []byte(infoPlist), 0660); err != nil {
|
||||
return err
|
||||
}
|
||||
icon := *iconPath
|
||||
if icon == "" {
|
||||
icon = filepath.Join(bi.pkgDir, "appicon.png")
|
||||
}
|
||||
if _, err := os.Stat(icon); err == nil {
|
||||
assetPlist, err := iosIcons(bi, tmpDir, app, icon)
|
||||
if _, err := os.Stat(bi.iconPath); err == nil {
|
||||
assetPlist, err := iosIcons(bi, tmpDir, app, bi.iconPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+17
-10
@@ -24,7 +24,7 @@ import (
|
||||
var (
|
||||
target = flag.String("target", "", "specify target (ios, tvos, android, js).\n")
|
||||
archNames = flag.String("arch", "", "specify architecture(s) to include (arm, arm64, amd64).")
|
||||
minsdk = flag.Int("minsdk", 16, "specify minimum supported Android platform sdk version (e.g. 28 for android28 a.k.a. Android 9 Pie).")
|
||||
minsdk = flag.Int("minsdk", 0, "specify the minimum supported operating system level")
|
||||
buildMode = flag.String("buildmode", "exe", "specify buildmode (archive, exe)")
|
||||
destPath = flag.String("o", "", "output file or directory.\nFor -target ios or tvos, use the .app suffix to target simulators.")
|
||||
appID = flag.String("appid", "", "app identifier (for -buildmode=exe)")
|
||||
@@ -67,7 +67,7 @@ func flagValidate() error {
|
||||
return errors.New("please specify -target")
|
||||
}
|
||||
switch *target {
|
||||
case "ios", "tvos", "android", "js":
|
||||
case "ios", "tvos", "android", "js", "windows":
|
||||
default:
|
||||
return fmt.Errorf("invalid -target %s", *target)
|
||||
}
|
||||
@@ -96,6 +96,8 @@ func build(bi *buildInfo) error {
|
||||
return buildIOS(tmpDir, *target, bi)
|
||||
case "android":
|
||||
return buildAndroid(tmpDir, bi)
|
||||
case "windows":
|
||||
return buildWindows(tmpDir, bi)
|
||||
default:
|
||||
panic("unreachable")
|
||||
}
|
||||
@@ -188,13 +190,6 @@ func buildIcons(baseDir, icon string, variants []iconVariant) error {
|
||||
for _, v := range variants {
|
||||
v := v
|
||||
resizes.Go(func() (err error) {
|
||||
scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}})
|
||||
op := draw.Src
|
||||
if v.fill {
|
||||
op = draw.Over
|
||||
draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
|
||||
}
|
||||
draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil)
|
||||
path := filepath.Join(baseDir, v.path)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
@@ -208,8 +203,20 @@ func buildIcons(baseDir, icon string, variants []iconVariant) error {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
return png.Encode(f, scaled)
|
||||
return png.Encode(f, resizeIcon(v, img))
|
||||
})
|
||||
}
|
||||
return resizes.Wait()
|
||||
}
|
||||
|
||||
func resizeIcon(v iconVariant, img image.Image) *image.NRGBA {
|
||||
scaled := image.NewNRGBA(image.Rectangle{Max: image.Point{X: v.size, Y: v.size}})
|
||||
op := draw.Src
|
||||
if v.fill {
|
||||
op = draw.Over
|
||||
draw.Draw(scaled, scaled.Bounds(), &image.Uniform{color.White}, image.Point{}, draw.Src)
|
||||
}
|
||||
draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), op, nil)
|
||||
|
||||
return scaled
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
func buildWindows(tmpDir string, bi *buildInfo) error {
|
||||
builder := &windowsBuilder{TempDir: tmpDir, BuildInfo: bi}
|
||||
builder.DestDir = *destPath
|
||||
if builder.DestDir == "" {
|
||||
builder.DestDir = bi.pkgPath
|
||||
}
|
||||
|
||||
name := bi.name
|
||||
if *destPath != "" {
|
||||
if filepath.Ext(*destPath) != ".exe" {
|
||||
return fmt.Errorf("invalid output name %q, it must end with `.exe`", *destPath)
|
||||
}
|
||||
name = filepath.Base(*destPath)
|
||||
}
|
||||
name = strings.TrimSuffix(name, ".exe")
|
||||
sdk := bi.minsdk
|
||||
if sdk > 10 {
|
||||
return fmt.Errorf("invalid minsdk (%d) it's higher than Windows 10", sdk)
|
||||
}
|
||||
version := strconv.Itoa(bi.version)
|
||||
if bi.version > math.MaxUint16 {
|
||||
return fmt.Errorf("version (%d) is larger than the maximum (%d)", bi.version, math.MaxUint16)
|
||||
}
|
||||
|
||||
builder.Resources.Name = name
|
||||
builder.Manifest.Name = name
|
||||
builder.Manifest.WindowsVersion = sdk
|
||||
builder.Resources.Version = "1,0,0," + version
|
||||
builder.Manifest.Version = "1.0.0." + version
|
||||
|
||||
if err := builder.createIcon(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := builder.createManifest(); err != nil {
|
||||
return fmt.Errorf("can't create manifest: %v", err)
|
||||
}
|
||||
if err := builder.createResource(); err != nil {
|
||||
return fmt.Errorf("can't create resource: %v", err)
|
||||
}
|
||||
|
||||
if err := builder.buildResource(); err != nil {
|
||||
return fmt.Errorf("can't build the resources: %v", err)
|
||||
}
|
||||
|
||||
for _, arch := range builder.BuildInfo.archs {
|
||||
if err := builder.buildProgram(arch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
windowsResources struct {
|
||||
IconPath string
|
||||
ManifestPath string
|
||||
Version string
|
||||
Name string
|
||||
CompanyName string
|
||||
}
|
||||
windowsManifest struct {
|
||||
Version string
|
||||
WindowsVersion int
|
||||
Name string
|
||||
Arch string
|
||||
}
|
||||
windowsFiles struct {
|
||||
Resources windowsResources
|
||||
ResourcesPath string
|
||||
Manifest windowsManifest
|
||||
}
|
||||
)
|
||||
|
||||
type windowsBuilder struct {
|
||||
TempDir string
|
||||
DestDir string
|
||||
BuildInfo *buildInfo
|
||||
windowsFiles
|
||||
}
|
||||
|
||||
func (b *windowsBuilder) createIcon() (err error) {
|
||||
if _, err := os.Stat(b.BuildInfo.iconPath); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
iconFile, err := os.Open(b.BuildInfo.iconPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't read the icon located at %s: %v", b.BuildInfo.iconPath, err)
|
||||
}
|
||||
defer iconFile.Close()
|
||||
|
||||
iconImage, err := png.Decode(iconFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't decode the PNG file (%s): %v", b.BuildInfo.iconPath, err)
|
||||
}
|
||||
|
||||
b.Resources.IconPath = filepath.Join(b.TempDir, "appicon.ico")
|
||||
exeIcon, err := os.Create(b.Resources.IconPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("impossibe to create icon file at %s: %v", b.Resources.IconPath, err)
|
||||
}
|
||||
defer exeIcon.Close()
|
||||
|
||||
return convertPNGtoICO(exeIcon, iconImage)
|
||||
}
|
||||
|
||||
func convertPNGtoICO(w io.Writer, img image.Image) error {
|
||||
// The file must be in .ICO format.
|
||||
const (
|
||||
OffsetICONDIR int = 2 * 3
|
||||
OffsetICONDIRENTRY int = (4 * 1) + (2 * 2) + (4 * 2)
|
||||
)
|
||||
|
||||
sizes := []int{16, 32, 48, 64, 128, 256}
|
||||
|
||||
// ICONDIR structure
|
||||
if err := binary.Write(w, binary.LittleEndian, [3]uint16{0, 1, uint16(len(sizes))}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
headerOffset = OffsetICONDIR + (OffsetICONDIRENTRY * len(sizes))
|
||||
imageBuffer bytes.Buffer
|
||||
)
|
||||
for _, size := range sizes {
|
||||
imageOffset := imageBuffer.Len()
|
||||
scaledImage := resizeIcon(iconVariant{size: size, fill: false}, img)
|
||||
|
||||
if err := png.Encode(&imageBuffer, scaledImage); err != nil {
|
||||
return fmt.Errorf("can't encode image: %v", err)
|
||||
}
|
||||
|
||||
// ICONDIRENTRY 0-3 structure.
|
||||
// The width/height is defined from 0 to 255 (uint8). But "0" means 256px.
|
||||
if err := binary.Write(w, binary.LittleEndian, [4]uint8{uint8(size % 256), uint8(size % 256), 0, 0}); err != nil {
|
||||
return err
|
||||
}
|
||||
// ICONDIRENTRY 4-6 structure
|
||||
if err := binary.Write(w, binary.LittleEndian, [2]uint16{1, 32}); err != nil {
|
||||
return err
|
||||
}
|
||||
// ICONDIRENTRY 8-12 structure
|
||||
if err := binary.Write(w, binary.LittleEndian, [2]uint32{uint32(imageBuffer.Len() - imageOffset), uint32(headerOffset + imageOffset)}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err := io.Copy(w, &imageBuffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *windowsBuilder) createManifest() error {
|
||||
// The manifest have some information about the executable itself,
|
||||
// such as the supported Windows and Execution Level/Permissions.
|
||||
b.Resources.ManifestPath = filepath.Join(b.TempDir, "manifest_windows.xml")
|
||||
manifest, err := os.Create(b.Resources.ManifestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer manifest.Close()
|
||||
|
||||
return b.Manifest.encode(manifest)
|
||||
}
|
||||
|
||||
func (b *windowsBuilder) createResource() error {
|
||||
// The resource includes the icon and manifest previously created
|
||||
// it also defines the version and some other information about the
|
||||
// program and the developer.
|
||||
b.ResourcesPath = filepath.Join(b.TempDir, "main_windows.rc")
|
||||
resources, err := os.Create(b.ResourcesPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resources.Close()
|
||||
|
||||
return b.Resources.encode(resources)
|
||||
}
|
||||
|
||||
func (b *windowsBuilder) buildResource() error {
|
||||
cmd := exec.Command(
|
||||
"windres",
|
||||
b.ResourcesPath,
|
||||
filepath.Join(b.BuildInfo.pkgPath, "main_windows.syso"),
|
||||
)
|
||||
_, err := runCmd(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *windowsBuilder) buildProgram(arch string) error {
|
||||
dest := b.DestDir
|
||||
if len(b.BuildInfo.archs) > 1 {
|
||||
dest = filepath.Join(filepath.Dir(b.DestDir), b.Resources.Name+"_"+arch+".exe")
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"go",
|
||||
"build",
|
||||
"-ldflags=-H=windowsgui "+b.BuildInfo.ldflags,
|
||||
"-tags="+b.BuildInfo.tags,
|
||||
"-o", dest,
|
||||
b.BuildInfo.pkgPath,
|
||||
)
|
||||
cmd.Env = append(
|
||||
os.Environ(),
|
||||
"GOOS=windows",
|
||||
"GOARCH="+arch,
|
||||
)
|
||||
_, err := runCmd(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *windowsManifest) encode(w io.Writer) error {
|
||||
t := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="{{.Name}}" version="{{.Version}}" />
|
||||
<description>{{.Name}}</description>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
{{if (le .WindowsVersion 10)}}<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
{{end}}
|
||||
{{if (le .WindowsVersion 9)}}<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||
{{end}}
|
||||
{{if (le .WindowsVersion 8)}}<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||
{{end}}
|
||||
{{if (le .WindowsVersion 7)}}<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||
{{end}}
|
||||
{{if (le .WindowsVersion 6)}}<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
|
||||
{{end}}
|
||||
</application>
|
||||
</compatibility>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>`
|
||||
template, err := template.New("manifest").Parse(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return template.Execute(w, f)
|
||||
}
|
||||
|
||||
func (f *windowsResources) encode(w io.Writer) error {
|
||||
const t = `{{if .IconPath}}#define IDI_ICON1 1
|
||||
IDI_ICON1 ICON "{{escapePath .IconPath}}"{{end}}
|
||||
|
||||
#define IDI_MANIFEST 1
|
||||
IDI_MANIFEST 24 "{{escapePath .ManifestPath}}"
|
||||
|
||||
#define IDI_VERSION 1
|
||||
IDI_VERSION VERSIONINFO
|
||||
FILEVERSION {{.Version}}
|
||||
PRODUCTVERSION {{.Version}}
|
||||
FILEFLAGSMASK 0X3FL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS 0X40004L
|
||||
FILETYPE 0X1L
|
||||
FILESUBTYPE 0x0L
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "04000400"
|
||||
BEGIN
|
||||
VALUE "ProductVersion", "{{.Version}}"
|
||||
VALUE "FileVersion", "{{.Version}}"
|
||||
VALUE "FileDescription", "{{.Name}}"
|
||||
VALUE "ProductName", "{{.Name}}"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x0400, 0x0400
|
||||
END
|
||||
END`
|
||||
template, err := template.New("rc").Funcs(template.FuncMap{"escapePath": func(s string) string {
|
||||
return strings.Replace(s, `\`, `\\`, -1)
|
||||
}}).Parse(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return template.Execute(w, f)
|
||||
}
|
||||
Reference in New Issue
Block a user