Что первым приходит в голову разработчика при слове «Go»? Google и микросервисы? Я тоже так думал, но реальность оказалась значительно интересней.
Волшебный мир Windows
Эта статья родилась внезапно — из профессионального спора о реалиях и возможностях языка Go, которые как оказалось выходят сильно далеко за рамки его традиционной сферы примененения.
Немного матчасти для тех кто не знает об этом языке:
Go (часто также golang) — компилируемый многопоточный язык программирования, разработанный внутри компании Google[11]. Разработка Go началась в сентябре 2007 года, его непосредственным проектированием занимались Роберт Гризмер, Роб Пайк и Кен Томпсон[12], занимавшиеся до этого проектом разработки операционной системы Inferno. Официально язык был представлен в ноябре 2009 года.
Я как и наверное большинство разработчиков считал Golang всего лишь новомодной корпоративной игрушкой, призванной подсадить широкие программисткие массы на очередную технологию «корпорации добра» — создавался этот язык внутри Гугла и для задач Гугла, которые разумеется сильно отличаются от обывательских.
Поэтому когда мне показали работу Golang с WinAPI «из коробки» я был сильно удивлен — в более серьезных языках вроде C/C++ работа c внутренностями Windows всегда выглядела куда более монструозной. Так и родилась эта замечательная статья.
Что мы будем в этот раз творить:
Desktop-приложение с настоящим интерфейсом, с учетом реалий Windows, которое запустит встроенный вебсервер, с методом REST API на ассемблере.
Еще будет загрузка графического файла и установка его в качестве обоев — через WinAPI.
Плюс небольшой обход файрвола, чтобы не показывался вот этот раздражающий экран с предупреждением:
Надеюсь описанное в статье удивит даже опытных разработчиков на Golang.
Собственно вот:
А так выглядит работа с системным треем:
Еще у нас будет стандартный модальный диалог:
И встроенный веб-сервер, с веб-интерфейсом:
Ну и по традиции весь проект целиком выложен на Github.
Сборка и запуск
Начну с банального — как всю эту радость собрать и запустить.
Первым делом разумеется надо скачать и установить Go:
Для Windows уже давно существуют готовые официальные сборки Golang, даже с инсталлятором.
Взять можно с официального сайта Golang, вот тут.
Я использовал последнюю на момент написания статьи версию 1.22.5, но язык столь бурно развивается, что не удивлюсь если выйдет более новая версия еще до завершения статьи.
Разработка проекта происходила в Visual Studio Code, который давно и официально поддерживает Go:
Теперь самое интересное:
для сборки проекта использовались не обычные Makefile и не шелл-скрипты — так характерные для проектов на «гошечке», а целая отдельная внешняя система сборки — Magefile.
Ставится она множеством разных способов, я использовал вот такой:
git clone https://github.com/magefile/mage
cd mage
go run bootstrap.go
После установки в окружении появляется бинарник mage, отвечающий за сборку:
Забираем проект:
git clone https://github.com/alex0x08/golang-winapi-asm.git
Скачиваем и устанавливаем зависимости:
mage install
Собираем:
mage build
Если сборка прошла успешно, в текущем каталоге будет файл ungoogled-go.exe, который можно свободно перемещать и запускать на пользовательских компьютерах — он полностью статичный и не зависит от установленного Golang.
Опционально можно запустить:
mage generate
Этой командой запустится генерация файлов add.s и stub.go — для метода на ассемблере.
Стоит также отметить, что конечная и отладочная сборка немного отличаются, разделение происходит путем проброса параметра:
-X main.DebugMode=false
которым изменится значение глобальной переменной — флагом отладочного режима, который в свою очередь немного влияет на поведение программы.
Теперь начинаем разбираться как оно все работает.
Приложение Windows
Если попробовать собрать и запустить в Windows классический «Hello world» на C:
#include <stdio.h>
int main() {
printf("Hello, World!");
return 0;
}
то вместо ожидаемого пустого графического окна запустится страшная черная консоль как на снимке выше.
Это происходит потому что в Windows для графических программ используется другая точка запуска (entry point):
Every Windows program includes an entry-point function named either WinMain or wWinMain.
И если уж жизнь вас заставила разрабатывать на Golang под Windows, еще и с графическим интерфейсом, то стоит «гошечке» об этом сообщить, добавив флаг:
-H windowsgui
в параметры ldflags.
Целиком это выглядит вот так:
go build -ldflags "-H windowsgui"
Помимо этого, я указываю режим сборки exe:
-buildmode=exe
Build the listed main packages and everything they import into
executables. Packages not named main are ignored.
для того чтобы получить в итоге сборки один большой и переносимый запускаемый exe файл.
Golang и WinAPI
Стоит для начала пояснить для непосвященных — в чем вообще заключается сложность работы с WinAPI.
Для примера возьмем официальный «Hello world» на C++ под Windows:
#ifndef UNICODE
#define UNICODE
#endif
#include <windows.h>
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg,
WPARAM wParam,
LPARAM lParam);
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE,
PWSTR pCmdLine, int nCmdShow)
{
// Register the window class.
const wchar_t CLASS_NAME[] = L"Sample Window Class";
WNDCLASS wc = { };
wc.lpfnWndProc = WindowProc;
wc.hInstance = hInstance;
wc.lpszClassName = CLASS_NAME;
RegisterClass(&wc);
// Create the window.
HWND hwnd = CreateWindowEx(
0, // Optional window styles.
CLASS_NAME, // Window class
L"Learn to Program Windows", // Window text
WS_OVERLAPPEDWINDOW, // Window style
// Size and position
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
NULL, // Parent window
NULL, // Menu
hInstance, // Instance handle
NULL // Additional application data
);
if (hwnd == NULL)
{
return 0;
}
ShowWindow(hwnd, nCmdShow);
// Run the message loop.
MSG msg = { };
while (GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return 0;
}
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg,
WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_DESTROY:
PostQuitMessage(0);
return 0;
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
// All painting occurs here, between BeginPaint and EndPaint.
FillRect(hdc, &ps.rcPaint, (HBRUSH) (COLOR_WINDOW+1));
EndPaint(hwnd, &ps);
}
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
Ну что много понятного?
А все потому что 90% кода даже в столь простом приложении не имеют никакого отношения к C++, а являются структурами, макросами или функциями самого WinAPI.
От C++ тут только примитивные типы (int) и управляющие конструкции (case, while).
Поэтому задача как-то серьезно взаимодействовать с WinAPI (дальше чем разовый вызов какой-то функции) — всегда была, есть и будет сложной.
А разработка под Windows является отдельной специальной дисциплиной, чемпионы которой запросто могут не знать обычный C/C++ вообще и всю разработку (даже серверную) вести на инструментах WinAPI.
Но вернемся к нашей «гошечке».
Go далеко не C++ и является определенной экзотикой в мире Windows-разработки, по крайней мере за пределами кампусов Google.
Но внезапно оказалось, что поддержка WinAPI в нем очень даже неплоха.
Взгляните как выглядит вызов WinAPI функции для установки обоев на Golang:
var (
user32DLL = windows.NewLazyDLL("user32.dll")
procSystemParamInfo = user32DLL.NewProc("SystemParametersInfoW")
)
func main() {
imagePath, _ := windows.UTF16PtrFromString(`image.jpg`)
fmt.Println("[+] Changing background now...")
procSystemParamInfo.Call(20, 0, uintptr(unsafe.
Pointer(imagePath)), 0x001A)
}
Тут так красиво скрыты все скользкие моменты вроде передачи указателя на участок памяти, где лежит путь до файла с картинкой:
unsafe.Pointer(imagePath)
или вопроса с кодировками:
windows.UTF16PtrFromString(`image.jpg`)
..что просто диву даешься.
Вот так выглядит работа этого маленького приложения:
Не буду разбирать весь код, поскольку вот тут лежит отдельная большая статья, в которой все уже подробно расписано.
Скажу лишь что благодаря столь серьезной поддержке WinAPI, получилось создать этот тестовый проект и не утопить читателя утонуть в деталях реализации.
WinAPI и графический интерфейс
Сначала я честно попытался реализовать вообще всю логику работы с WinAPI полностью вручную, как в этом примере со стандартным диалоговым окном:
import (
"syscall"
"unsafe"
)
// MessageBox of Win32 API.
func MessageBox(hwnd uintptr, caption, title string, flags uint) int {
ret, _, _ := syscall.NewLazyDLL("user32.dll").
NewProc("MessageBoxW").Call(
uintptr(hwnd),
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(caption))),
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))),
uintptr(flags))
return int(ret)
}
// MessageBoxPlain of Win32 API.
func MessageBoxPlain(title, caption string) int {
const (
NULL = 0
MB_OK = 0
)
return MessageBox(NULL, caption, title, MB_OK)
}
И оно даже работало.
Но только объем кода очень быстро вырос до былинных размеров и никак не влезал в масштаб статьи.
Поэтому от такого подхода пришлось отказаться, оставив лишь работу с системным треем.
Все остальное я отдал на откуп готовым библиотекам.
В частности построение окон и обработку событий были реализованы через библиотеку Windigo. — хотя это по-сути лишь набор готовых биндингов для функций WinAPI.
Вот так выглядит в работе демо-приложение на Windigo:
func main()
Запуск приложения Go согласно спецификации начинается с функции func main() в пакете main:
A complete program is created by linking a single, unimported package called the main package with all the packages it imports, transitively. The main package must have package name main and declare a function main that takes no arguments and returns no value.
Первая же строка внутри main() нашего проекта нуждается в пояснении:
runtime.LockOSThread()
Этот вызов из пакета runtime нужен для того чтобы все goroutines (легковесные потоки Go) выполнялись в отдельных системных потоках каждый.
В нашем случае это необходимо для взаимодействия с системным потоком, отвечающим за графический интерфейс:
A goroutine should call LockOSThread before calling OS services or non-Go library functions that depend on per-thread state.
Следующим шагом происходит вызов функции, отвечающей за построение графического интерфейса:
mainWindow = newMyWindow()
Разберем как формируются и связываются графические элементы, в нашем проекте за это отвечает функция:
func newMyWindow() *MyWindow
Возвращаемая структура:
type MyWindow struct {
wnd ui.WindowMain
lblName ui.Static
txtName ui.Edit
btnShow ui.Button
}
содержит все графические элементы — само окно (wnd), текстовую метку (lblName), текстовое поле (txtName) и кнопку (btnShow).
Первым делом происходит настройка создаваемого окна:
opts := ui.WindowMainOpts().
ClassStyles(co.CS_NOCLOSE).
Title("Tiny Server").
ClientArea(win.SIZE{Cx: 600, Cy: 245})
С помощью константы co.CS_NOCLOSE отключается кнопка закрытия окна:
CS_NOCLOSE 0x0200 Disables Close on the window menu.
Ну и дальше задается заголовок и размеры создаваемого окна — тут все просто.
Сложно чуть ниже:
if DebugMode == "false" {
// ID of icon resource, see resources folder
// does not work in debug mode
opts = opts.IconId(101)
}
Тут указывается иконка окна в виде числового ID ресурса, файл с ресурсами minimal.syso был взят из демо-проекта Windigo:
A syso file, ready to use, that contains the icon and the manifest. Just place it at the root folder of your project. You can load the icon using the resource ID 101.
Следующим шагом происходит вызов сложной цепочки инициализации окна:
// create main window
wnd := ui.NewWindowMain(opts)
в конце которой вызывается широко известная функция WinAPI CreateWindowEx, используемая для создания нового графического окна.
Дальше происходит создание отдельных элементов:
// build UI
me := &MyWindow{
wnd: wnd,
// add label
lblName: ui.NewStatic(wnd,
ui.StaticOpts().
Text("Server log").
Position(win.POINT{X: 10, Y: 22}),
),
// add shutdown button
btnShow: ui.NewButton(wnd,
ui.ButtonOpts().
Text("&Quit").
Position(win.POINT{X: 510, Y: 17}),
),
// add message log (text area)
txtName: ui.NewEdit(wnd,
ui.EditOpts().
WndStyles(co.WS_CHILD|co.WS_VISIBLE|co.WS_VSCROLL).
CtrlStyles(co.ES_AUTOHSCROLL|co.ES_MULTILINE|co.ES_LEFT|co.ES_READONLY).
Position(win.POINT{X: 0, Y: 45}).
Size(win.SIZE{Cx: 600, Cy: 200}),
),
}
Важно отметить, что в случае WinAPI за любой ввод текста отвечает один и тот же компонент CEdit, c разным набором настроек:
co.ES_MULTILINE — указание на ввод нескольких строк (как textarea в HTML);
co.WS_VISIBLE — окно не будет скрыто;
co.WS_VSCROLL — вертикальный скролл;
co.ES_AUTOHSCROLL — автоматический горизонтальный скролл;
co.ES_READONLY — только для чтения.
Дальше происходит настройка обработчика кнопки для завершения работы приложения:
// setup handler on 'shutdown' button click
me.btnShow.On().BnClicked(func() {
// start confirmation dialog
resp := me.wnd.Hwnd().MessageBox("Quit application?",
"Confirm quit", co.MB_YESNO)
// if user clicked 'YES' - shutdown application
if resp == co.ID_YES {
appendToLog("Exiting..")
if httpSrv != nil {
if err := httpSrv.Close(); err != nil {
fmt.Printf("HTTP close error: %v", err)
}
}
me.wnd.Hwnd().DestroyWindow()
os.Exit(0)
}
})
По клику запускается модальный диалог с блокировкой текущего треда:
resp := me.wnd.Hwnd().MessageBox("Quit application?",
"Confirm quit", co.MB_YESNO)
Если пользователь нажал кнопку «Yes» (т.е подтвердил операцию), происходит завершение работы HTTP-сервера:
if httpSrv != nil {
if err := httpSrv.Close(); err != nil {
fmt.Printf("HTTP close error: %v", err)
}
}
закрытие главного окна приложения:
me.wnd.Hwnd().DestroyWindow()
и завершение работы:
os.Exit(0)
Следущим шагом из функции main мы загружаем иконку, используемую в трее:
var trayIcon win.HICON
// Load icon
// in debug mode, there are no resources available, so we need to load
// icons from FS
if DebugMode == "false" {
trayIcon = win.HICON(
win.GetModuleHandle(win.StrOptNone()).LoadImage(
win.ResIdInt(101),
co.IMAGE_ICON,
16, 16,
co.LR_DEFAULTCOLOR,
))
} else {
trayIcon = win.HICON(
win.GetModuleHandle(win.StrOptNone()).LoadImage(
win.ResIdStr("gopher.ico"),
co.IMAGE_ICON,
16, 16,
co.LR_DEFAULTCOLOR|co.LR_LOADFROMFILE,
))
}
Используется разная логика для режима отладки и запуска финального бинарника, потому что в готовом приложении иконка будет находиться в ресурсах — специальном файле, упакованном вместе с приложением.
А во время отладки либо запуска вроде:
go run main.go
ресурсов не будет, поэтому придется загружать иконку непосредственно с файловой системы.
Дальше мы настраиваем дополнительные обработчики, в первую очередь добавляем обработку на закрытие главного окна приложения:
// close systray on main window destroy
mainWindow.wnd.On().WmDestroy(func() {
if tray != nil {
tray.Dispose()
}
})
При закрытии главного окна, произойдет и автоматическое закрытие трея — не будет эффекта потерянной инонки, когда приложение уже закрылось, а его иконка до сих пор отображается в трее.
Дальше мы вешаем обработчик на активацию главного окна, для того чтобы поймать момент полной готовности и отображения и запустить сервер:
var configured = false // check for action that runs only once
mainWindow.wnd.On().WmActivate(func(p wm.Activate) {
// we need to run our handler logic only once at start
if configured {
return
}
configured = true
go startServer()
})
Столь отложенный старт необходим для большей интерактивности:
метод startServer () пишет сообщения в «графический лог», если он не будет полностью инциализирован — сообщения пропадут.
Проблема заключается в том что этот обрабочик будет запускаться и на повторную активацию (например после сворачивания окна) — чтобы логика не отрабатывала повторно стоит проверка на переменную configured, которая работает в качестве флага «инициализация завершена».
Ну и сам запуск HTTP-сервера происходит через отдельный поток, чтобы не блокировать работу обработчика:
go startServer()
Последним мы добавляем инициализацию трея по событию создания главного окна:
// action on windows create
// runs once
mainWindow.wnd.On().WmNcCreate(func(p wm.Create) bool {
// create systray
tray := systray.CreateSysTray()
// set handler on icon click - just focus on main window
systray.SetTrayClickHandler(func() {
systray.ShowWindow(uintptr(mainWindow.wnd.Hwnd()),
systray.SW_SHOWNORMAL)
})
tray.SetIcon(uintptr(trayIcon))
tray.SetTooltip("Tiny Server: click me to show main window.")
return true
})
Нужно это по той простой причине что только на этой стадии появляется настоящий window handle:
mainWindow.wnd.Hwnd()
с помощью которого возможно взаимодействовать с окном:
systray.ShowWindow(uintptr(mainWindow.wnd.Hwnd())
До этого момента (т.е. до вызова обработчика) HWND нашего окна будет пустым.
Наконец финальный шаг в функции main() это запуск блокирующего цикла обработки cобытий:
mainWindow.wnd.RunAsMain()
После вызова этого метода, приложение начнет реагировать на события вроде нажатия клавиш или кликов мышкой.
Теперь разберем работу с системным треем — как пример работы с чистым WinAPI.
Работа с системным треем
Разумеется есть способ проще:
взять одну из готовых библиотек, тем более что есть универсальные — сразу для Windows, MacOS и Linux и всей кучи разных сред окружения.
Но фана ради и пользы обучения для, был избран более сложный путь — закат солнца вручную взаимодействие с системным треем только через WinAPI.
Все функции, относящиеся к этой задаче находятся в пакете systray:
import (
..
systray "github.com/alex0x08/ungoogled-go/systray"
..
)
Место с которого начинается инициализация системного трея выглядит вот так:
// creates systray icon
func CreateSysTray() *TrayIcon {
// first, create hidden message-only window
hwnd, err := createMessageWindow()
if err != nil {
panic(err)
}
// create systray with parent = our message-only window
ti, err := newTrayIcon(hwnd)
if err != nil {
panic(err)
}
return ti
}
Как видите тут не используется родительское окно — вместо него создается специальное скрытое окно, только для приема сообщений:
func createMessageWindow() (uintptr, error) {
hInstance, err := GetModuleHandle(nil)
if err != nil {
return 0, err
}
wndClass := windows.StringToUTF16Ptr("MyWindow")
var wcex WNDCLASSEX
wcex.CbSize = uint32(unsafe.Sizeof(wcex))
wcex.LpfnWndProc = windows.NewCallback(wndProc)
wcex.HInstance = hInstance
wcex.LpszClassName = wndClass
if _, err := RegisterClassEx(&wcex); err != nil {
return 0, err
}
hwnd, err := CreateWindowEx(
0,
wndClass,
windows.StringToUTF16Ptr(""),
WS_OVERLAPPED,
CW_USEDEFAULT,
CW_USEDEFAULT,
400,
300,
uintptr(HWND_MESSAGE),
0,
hInstance,
nil)
if err != nil {
return 0, err
}
return hwnd, nil
}
Ключевое тут — вызов функции WinAPI CreateWindowEx, c указанием специального флага HWND_MESSAGE:
hwnd, err := CreateWindowEx(
..
uintptr(HWND_MESSAGE)
..
)
Благодаря этому флагу можно создать невидимое окно и заставить его обработчик принимать сообщения системного трея:
// this is main window function
// see https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-wndproc
func wndProc(hWnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
switch msg {
case TrayIconMsg:
nmsg := LOWORD(uint32(lParam))
// if user clicked on tray icon
if nmsg == WM_LBUTTONDOWN {
// if callback function exist
if trayClickCallback != nil {
trayClickCallback()
}
}
case WM_DESTROY:
PostQuitMessage(0)
default:
r, _ := DefWindowProc(hWnd, msg, wParam, lParam)
return r
}
return 0
}
Да, это все тот же старый добрый WndProc , описанный выше в статье и хорошо знакомый любым Windows-разработчикам.
Блок внутри:
..
case TrayIconMsg:
nmsg := LOWORD(uint32(lParam))
// if user clicked on tray icon
if nmsg == WM_LBUTTONDOWN {
// if callback function exist
if trayClickCallback != nil {
trayClickCallback()
}
}
..
отвечает за обработку сообщений системного трея.
Функция, которая отрабатывает по событию клика левой кнопки мыши (WM_LBUTTONDOWN) выглядит вот так:
systray.SetTrayClickHandler(func() {
systray.ShowWindow(uintptr(mainWindow.wnd.Hwnd())
, systray.SW_SHOWNORMAL)
})
А systray.ShowWindow() это фактически обретка над чистым WinAPI:
func ShowWindow(hWnd uintptr, nCmdShow int32) (int32, error) {
r, _, err := procShowWindow.Call(hWnd, uintptr(nCmdShow))
if r == 0 {
return 0, err
}
return int32(r), nil
}
поскольку procShowWindow — чистый definition для фукнции WinAPI ShowWindow:
..
libuser32 = windows.NewLazySystemDLL("user32.dll")
..
procShowWindow = libuser32.NewProc("ShowWindow")
..
Словом, уровень интеграции с WinAPI и легкости его применения поражает воображение.
Лог
Он же «журнал работы» — отображает события в приложении в центральной части рабочей области. Логика записи выглядит следующим образом:
// appends to UI log
func appendToLog(message string) {
// could be no window yet
if mainWindow == nil || mainWindow.txtName == nil {
fmt.Println(message)
return
}
// window could be not visible yet
// and attempt to add message will raise an exception
if !mainWindow.txtName.Hwnd().IsWindowVisible() {
fmt.Println(message)
return
}
// get current text
txt := mainWindow.txtName.Text()
// to avoid overflow
if len(txt) > 512 {
txt = ""
}
b := strings.Builder{}
b.WriteString(txt) // append existing text
b.WriteString(message) // append new message
b.WriteString("\r\n") // this is Windows, so \r\n, not \n !
// and finally set updated text (yep, there is no append, sorry)
mainWindow.txtName.SetText(b.String())
}
Кроме достаточно очевидного пропуска записи в случае неполной инициализации, тут есть еще вот такая логика:
if !mainWindow.txtName.Hwnd().IsWindowVisible() {
fmt.Println(message)
return
}
Нужно это потому, что CEdit не даст изменить текст внутри если сам компонент еще не отображается, а попытка вызова метода API изменения текста вызовет ошибку.
Также внезапно (хотя для кого как) оказалось что стандартный компонент Windows для ввода не поддерживает логику добавления (append) — только полную замену всего текстового блока:
// get current text
txt := mainWindow.txtName.Text()
// to avoid overflow
if len(txt) > 512 {
txt = ""
}
b := strings.Builder{}
b.WriteString(txt) // append existing text
b.WriteString(message) // append new message
b.WriteString("\r\n") // this is Windows, so \r\n, not \n !
// and finally set updated text (yep, there is no append, sorry)
mainWindow.txtName.SetText(b.String())
Поэтому с точки зрения современной разработки это выглядит как колхоз, но увы — таковы реалии WinAPI.
Встроенный HTTP-сервер
В составе Golang идет готовый встраиваемый HTTP-сервер (пакет «net/http»), с примитивами обработчиков для типовых действий.
С его помощью удалось минимальными силами реализовать весь тестовый функционал, метод инициализации и запуска встроенного HTTP-сервера выглядит вот так:
// starts HTTP server
func startServer() {
appendToLog(fmt.Sprintf("Starting, debug mode: %s", DebugMode))
// firewall bypass does not work correctly in debug mode
if DebugMode == "false" {
server.AddAppFirewallRule()
appendToLog("Added firewall rule..")
}
// create request multiplexer, see https://pkg.go.dev/net/http#ServeMux
mux := http.NewServeMux()
// test assembler method
mux.HandleFunc("/asmtest", server.TestAsmMethod)
// upload & set wallpaper image
mux.HandleFunc("/upload", server.UploadHandler)
// default handler
mux.HandleFunc("/", server.IndexHandler)
// if this is production mode - bind to all interfaces
if DebugMode == "false" {
httpSrv = &http.Server{
Addr: ":8090",
Handler: mux,
}
} else {
// otherwise - bind to localhost (firewall bypass
// does not work in debug mode)
httpSrv = &http.Server{
Addr: "localhost:8090",
Handler: mux,
}
}
appendToLog(fmt.Sprintf("Server started at %s", httpSrv.Addr))
// set logging handler
server.SetMessageLogHandler(appendToLog)
httpSrv.ListenAndServe() // here will be lock
}
Разберем что тут происходит, первым шагом идет запись в лог:
appendToLog(fmt.Sprintf("Starting, debug mode: %s", DebugMode))
Затем попытка отключить NAG-screen файрвола:
// firewall bypass does not work correctly in debug mode
if DebugMode == "false" {
server.AddAppFirewallRule()
appendToLog("Added firewall rule..")
}
Как это работает подробно разобрано ниже, пока замечу что этот обход не работает в режиме отладки, поэтому тут и стоит такая странная на первый взгляд проверка.
Следующим шагом происходит инстанциация мультиплексора запросов:
// create request multiplexer, see https://pkg.go.dev/net/http#ServeMux
mux := http.NewServeMux()
и связывание методов обработки с контекстом URL:
// test assembler method
mux.HandleFunc("/asmtest", server.TestAsmMethod)
// upload & set wallpaper image
mux.HandleFunc("/upload", server.UploadHandler)
// default handler
mux.HandleFunc("/", server.IndexHandler)
Т.е. по какой ссылке будет отвечать каждый обработчик.
Логика всех обработчиков разобрана чуть ниже, а пока пройдем дальше по логике инициализации HTTP-сервера:
// if this is production mode - bind to all interfaces
if DebugMode == "false" {
httpSrv = &http.Server{
Addr: ":8090",
Handler: mux,
}
} else {
// otherwise - bind to localhost (firewall bypass
// does not work in debug mode)
httpSrv = &http.Server{
Addr: "localhost:8090",
Handler: mux,
}
}
Вся эта простыня нужна по той простой причине что в Golang нет тернаров, т.е нельзя сделать логику одной строкой вроде:
Addr = DebugMode? "localhost:8090" : ":8090"
Как это было бы в Java или Typescript.
Поэтому надо было либо делать отдельную функцию, отдающую адрес, внутри которой вставлять проверку на DebugMode, либо сделать как на примере выше — два повторяющихся блока.
Дальше по логике происходит установка обработчика логирования:
// set logging handler
server.SetMessageLogHandler(appendToLog)
Со стороны пакета сервера он вызвается вот так:
// logs message with callback on UI
func logMessage(message string) {
if messageLogCallback != nil {
messageLogCallback(message)
} else {
fmt.Println(message)
}
}
А вот так выглядит пример конечного использования:
logMessage(fmt.Sprintf("Background changed to %s", dst.Name()))
Наконец последним шагом происходит запуск самого HTTP-сервера:
httpSrv.ListenAndServe() // here will be lock
Обратите внимание что httpSrv объявлен как глобальная переменная:
var (
tray *systray.TrayIcon
httpSrv *http.Server
mainWindow *MyWindow
//You can only set string variables with -X linker flag. From the docs:
DebugMode = "true"
)
Это нужно чтобы иметь возможность остановить HTTP-сервер при завершении работы (graceful shutdown):
if httpSrv != nil {
if err := httpSrv.Close(); err != nil {
fmt.Printf("HTTP close error: %v", err)
}
}
Обход файрвола
И не надо так подозрительно смотреть — речь про вполне себе документированное API, позволяющее пропустить вот такое откровенно дурацкое подтверждение:
Как и в случае с интерфейсом, было принято волевое решение использовать готовый пакет с биндингами:
This is a package for controlling the Windows Filtering Platform (WFP), also known as the Windows firewall.
С его помощью вся логика свелась к вот такой простой функции, взятой из issue в Github проекта и немного переделанной:
// adds firewall rule via WinAPI to bypass confirmation screen
func AddAppFirewallRule() error {
session, err := wf.New(&wf.Options{
Name: "ungoogled session",
Dynamic: false,
})
if err != nil {
return err
}
defer session.Close()
guid, _ := windows.GenerateGUID()
execPath, _ := os.Executable()
appID, _ := wf.AppID(execPath)
err = session.AddRule(&wf.Rule{
ID: wf.RuleID(guid),
Name: "Ungoogled",
Layer: wf.LayerALEAuthRecvAcceptV4,
Weight: 800,
Conditions: []*wf.Match{
{
Field: wf.FieldALEAppID,
Op: wf.MatchTypeEqual,
Value: appID,
},
},
Action: wf.ActionPermit,
})
if err != nil {
return err
}
return nil
}
Не буду детально расписывать эту довольно сложно воспринимаемую логику — слишком уж тут много специфики WFP, читать устанете.
Если есть желание погрузиться в тему — вам в помощь вот такая замечательная статья от авторов пакета, где расписано в деталях внутренее устройство WFP и работа с его API.
Но если в кратце — тут происходит создание и применение нового правила фильтрации, которое разрешает входящие соединения для приложения, из которого выполняется вызов API.
Golang и ассемблер
Чтобы сразу закрыть все вопросы по поводу адекватности использования ассемблера из Go, вот вам небольшая цитата:
This example is taken from the AES package of the standard Go library. It makes use of Go Assembly to leverage Intel’s hardware support for AES, calling the AES-NI CPU instructions that can perform a “round” of encryption or decryption of the AES algorithm.
Да, как только начинается большая криптография и множественные вычисления на слабом железе — сразу с дальней полки достается пыльная книга по ассемблеру.
Разумеется не было открытием что язык, с самого своего начала имевший компилятор в нативный код умеет вызывать вставки на ассемблере. Открытием была легкость и простота с которой это делается.
Еще одним открытием оказались торчащие уши Plan 9:
The assembler is based on the input style of the Plan 9 assemblers, which is documented in detail elsewhere. If you plan to write assembly language, you should read that document although much of it is Plan 9-specific
А разгадка проста — один из авторов Go когда‑то работал над Plan 9:
Robert Pike (born 1956) is a Canadian programmer and author. He is best known for his work on the Go programming language while working at Google[1][2] and the Plan 9 operating system while working at Bell Labs, where he was a member of the Unix team.[1]
Но продолжим тему с ассемблером.
Вообщем чтобы не заморачиваться с созданием и линковкой ассемблерных вставок вручную, я использовал фреймворк Avo:
avo
makes high-performance Go assembly easier to write, review and maintain.
Самое важное что он дает:
Use Go control structures for assembly generation;
avo
programs are Go programs
И выглядит это именно так как звучит:
//go:build ignore
package main
import . "github.com/mmcloughlin/avo/build"
func main() {
TEXT("Add", NOSPLIT, "func(x, y uint64) uint64")
Doc("Add adds x and y.")
x := Load(Param("x"), GP64())
y := Load(Param("y"), GP64())
ADDQ(x, y)
Store(y, ReturnIndex(0))
RET()
Generate()
}
Это отдельная программа на Go, которая при запуске генерирует ассемблерный код в файле asm/add.s:
// Code generated by command: go run asm.go -out asmtest/add.s -stubs asmtest/stub.go. DO NOT EDIT.
#include "textflag.h"
// func Add(x uint64, y uint64) uint64
TEXT ·Add(SB), NOSPLIT, $0-24
MOVQ x+0(FP), AX
MOVQ y+8(FP), CX
ADDQ AX, CX
MOVQ CX, ret+16(FP)
RET
а также заголовочный файл на Go в файле stub.go:
// Code generated by command: go run asm.go -out asmtest/add.s -stubs asmtest/stub.go. DO NOT EDIT.
package ungoogled
// Add adds x and y.
func Add(x uint64, y uint64) uint64
Затем данные файлы линкуются с основным приложением, а вызов метода с ассемблерной вставкой выглядит вот так:
// a test API method to call function with Assembler inside
func TestAsmMethod(w http.ResponseWriter, req *http.Request) {
query := req.URL.Query()
fmt.Println("GET params were:", query)
param1, param2 := query.Get("param1"), query.Get("param2")
int1, _ := strconv.ParseUint(param1, 10, 64)
int2, _ := strconv.ParseUint(param2, 10, 64)
fmt.Fprintf(w, "int1: %v int2: %v \n", int1, int2)
// yep, check stub.go in asmtest
out := ungoogled.Add(int1, int2)
fmt.Fprintf(w, "result: %v \n", out)
logMessage(
fmt.Sprintf("Called asm method with params: %v , %v and result: %v",
int1, int2, out))
}
Лепота и благодать, другими словами.
Смена обоев через WinAPI и загрузку файлов
Просто для демонстрации возможностей, к достаточно стандартному функционалу по загрузке файлов был приделан вызов WinAPI функции для смены обоев на рабочем столе.
Сама форма загрузки выглядит максимально стандартно:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Upload an image</title>
</head>
<body>
<form
enctype="multipart/form-data"
action="/upload"
method="post">
<input type="file" name="imageFile" accept="image/*" />
<input type="submit" value="upload" />
</form>
</body>
</html>
Затем она зашивается в приложение с помощью go:embed:
//go:embed upload.html
var uploadTemplate string
Т.е. во время запуска приложения, переменная uploadTemplate будет содержать HTML-шаблон выше для загрузки картинки — зашивание происходит во время сборки.
За загрузку картинки отвечает функция:
func uploadFile(w http.ResponseWriter, r *http.Request) { .. }
Внутри стандартная скучная логика разборки multipart-формы и обработки загруженного файла - ее нет смысла описывать, зато дальше происходит кое-что интересное:
// build full path to image
imagePath, err := windows.UTF16PtrFromString(dst.Name())
Тут происходит формирование ссылки на UTF-16 строку, содержающую полный путь к загруженному файлу.
Затем эта ссылка используется для вызова API:
// call WinAPI to change wallpaper to just uploaded image
_, _, err = procSystemParamInfo.Call(20, 0,
uintptr(unsafe.Pointer(imagePath)), 0x001A)
// check for errors, respond 500 if any
if err, ok := err.(syscall.Errno); ok {
if err != 0 {
fmt.Println("Error :")
fmt.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
Вызывается функция SystemParametersInfoW, которая на самом деле используется для очень большого количества разных действий:
Retrieves or sets the value of one of the system-wide parameters. This function can also update the user profile while setting a parameter.
Нужный нам для смены обоев actionName называется SPI_SETDESKWALLPAPER который и указывается при вызове.
Эпилог
Разумеется я такой не один и уже достаточно много разработчиков по всему миру делятся своим опытом разработки на Go и WinAPI.
Надеюсь эта статья также добавит читателям восторгов и позволит взглянуть на любимый инструмент под другим углом
Приятного чтения!
Это немного отцезурированная версия моей статьи, оригинал которой доступен в нашем блоге.
0x08 Software
Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.
Оживляем давно умершее, чиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.
Комментарии (31)
kmeaw
21.08.2024 08:48Чуть более сложный пример использования win32 на Go - запуск программы с дополнительной библиотекой, как LD_PRELOAD в GNU/Linux: github.com/kmeaw/zdrct:inject.go
alex0x08 Автор
21.08.2024 08:48+1Это уж совсем скотство какое-то:
err = windows.CreateProcess( nil, S(exePath), nil, nil, false, windows.CREATE_SUSPENDED, nil, nil, &si, &pi, )
Тему с динамической загрузкой библиотек в Go не изучал, но все же полагаю есть какое-то более разумное решение чем через дочерний процесс.
kmeaw
21.08.2024 08:48Увы, в моём случае exePath указывает на чужую программу, написанную на C++. А для родного Go-кода есть плагины.
ImagineTables
21.08.2024 08:48+3Спасибо за статью. Всегда хотел посмотреть, как можно на Go работать с WinAPI. (Звучит как ирония, но это не ирония).
А разработка под Windows является отдельной специальной дисциплиной, чемпионы которой запросто могут не знать обычный C/C++ вообще и всю разработку (даже серверную) вести на инструментах WinAPI.
Тому есть причины. WinAPI —
древнее злодревняя система. Многие даже не представляют, насколько. Реймонд Чен как-то писал, что хейтеры обвиняют Майкрософт в нарушении стандартов по работе с Юникодом. На самом же деле, Майкрософт поддерживал Юникод задолго допоявления Юникодастандартизации комитетом языка Си. И, между прочим, к этому времени десятки, если не сотни тысяч программистов уже использовали вариант Майкрософт, что не помешало стандартизировать нечто совсем другое (видимо, назло монополисту и массовому программисту). Аналогично, многие другие вещи появились и были adopted в WinAPI раньшеи лучше, что и привело к массовому использованию MS-specific, особенно если всё равно запускать под виндами.Конечно, кросс-платформенные приложения так писать не стоит
главным образом, потому что не получится.
savostin
21.08.2024 08:48+9Эх, молодежь.... Да, так на заре писалось большинство программ под Windows - чистый WinAPI со всеми обработками message в бесконечном loop'е. Попривыкали блин фреймворками мыслить ;)
orefkov
21.08.2024 08:48+1Примерно в 1997 году баловался я Visual Basic. Так там оказалось так же можно импортировать функции из dll и вызывать их. Так я и начал знакомство с WinAPI. Потом купил компакт-диск с Visual C++ 4.2, самоучитель по C++ Подбельского, и книгу "Программирование в Windows на C++". Ну и понеслось... Осваивал самостоятельно, интернет был в диковинку. Раз повезло, и попался в магазине набор MSDN на 6 CD. Кладезь инфы, много лет пользовался. Так вот и стал C++ником. Под WinAPI правда давненько не работал. Эх, ностальгия...
alex0x08 Автор
21.08.2024 08:48попался в магазине набор MSDN на 6 CD. Кладезь инфы, много лет пользовался.
да, именно с него я когда-то начинал.
kmatveev
21.08.2024 08:48+2Статья отмечена как сложная, но рассуждения во вступлении не очень умные. Какие такие задачи Google отличаются от обывательских, и как это проявляется в языке общего назначения? Дальше статья обещает, что в Go работа с Win32API проще, чем в С и C++, и нагло врёт. Это биндинги к тем же самым функциям, только аргументы приходится обёртывать в unsafe.Pointer(). Но потом дошёл до вот этой части
Поэтому задача как-то серьезно взаимодействовать с WinAPI (дальше чем разовый вызов какой-то функции) — всегда была, есть и будет сложной.
А разработка под Windows является отдельной специальной дисциплиной, чемпионы которой запросто могут не знать обычный C/C++ вообще и всю разработку (даже серверную) вести на инструментах WinAPI.
Что за чушь? Нет ничего сложного в Win32API. И чего такого разработчики под Windows не знают в "обычном" C ?
Ещё одно враньё статьи - это обещанная работа с Win32API «из коробки». Судя по примеру в главе "WinAPI и графический интерфейс" из коробки есть только syscall, и приходится пользоваться либами, которые спрячут преобразования типов.
Ну и так, технические придирки. Почему вы всё время ссылаетесь на CEdit, это же класс из MFC ? В Win32API этот класс окна называется "Edit". Насчёт того, что он не поддерживает добавление текста - это вы плохо искали .
alex0x08 Автор
21.08.2024 08:48Статья отмечена как сложная, но рассуждения во вступлении не очень умные.
С козырей зашли, браво.
Какие такие задачи Google отличаются от обывательских, и как это проявляется в языке общего назначения?
У вас дома есть распределенный геокластер с тысячей сервисов обработки данных? Есть задачи централизованного управления и мониторинга всей этой кухни?
Соседи не жалуются? А то жужжит наверно адски.
Дальше статья обещает, что в Go работа с Win32API проще, чем в С и C++,
Лаконичнее, это когда исходного кода меньше, сам он чище и не вызывает приступов паники при просмотре.
Это биндинги к тем же самым функциям,
Есть всего один способ не использовать "биндинги к тем самым функциям" при работе с WinAPI из любого языка: устроиться в Microsoft и заняться там разработкой WinAPI.
Только в этом единственном случае вы не будете работать с клиентской стороны.
Что за чушь? Нет ничего сложного в Win32API.
Обожаю такой слог, сразу видно профессионала.
Если серьезно то WinAPI это отдельная область знаний, разбирающихся в этом разработчиков крайне мало — собственно как и в любой узкой нише.
Для иллюстрации этой сложности, в статье приведен официальный пример «Hello world» на C++ под Windows с отображением пустого окна через вызовы WinAPI функций.
Если для вас лично это просто то для большинства обычных (и тем более начинающих) разработчиков — нет.
И чего такого разработчики под Windows не знают в "обычном" C ?
Ситуация ровно обратная: обычные С-разработчики, писавшие для Linux или встариваемых систем мало чего поймут при разработке под Windows.
Для иллюстрации приведен пример с отдельной точкой входа WinMain. Опять же нет сомнений что для вас лично это прописные истины.
Ещё одно враньё статьи - это обещанная работа с Win32API «из коробки». Судя по примеру в главе "WinAPI и графический интерфейс" из коробки есть только syscall, и приходится пользоваться либами, которые спрячут преобразования типов.
Видимо не разобрались, преобразования типов как раз из коробки — обратите внимание на типы полей (их нет).
Почему вы всё время ссылаетесь на CEdit, это же класс из MFC ? В Win32API этот класс окна называется "Edit".
Ну потому что биндинги для CEdit уже были в готовом виде, реализовывать их самому для просто Edit в рамках статьи было бы слишком объемно.
Насчёт того, что он не поддерживает добавление текста - это вы плохо искали .
Ну ок, немного через одно место но возможно. Плюсую.
Но вообще спасибо за толковый разбор, хоть кто-то еще может такое на Хабре ;)
Vladicus
21.08.2024 08:48Смертный, успокойся...
Фишка в том, что вот эта обвязка в коде, даже не всегда обязательна. В том смысле, что это можно вполне зашаблонить и туда заглядывать "только по праздникам", а остальная часть работы с WinAPI, куда как более изящна и проста (как минимум для меня) чем в твоих примерах.
В целом, ты высказал хорошую мысль по поводу корпоративной побрякушки, и тут ты оказался прав. Я точно так же думал, думаю и буду думать. Ну, в отличии от тебя. К слову, ради интереса, глянь как устроен GLEW. Ну, тупо хидер глянь, как он цепляется к OpenGL и ведет обмен с апишкой. Тебе достаточно скачать два файлика под пару метров и у тебя актуальная версия огла. Да, внутри там трэш и угар, но ты этого не видишь, и на результате это не сказывается.
В общем, восторги неофита понимаю, но ты меня не убедил. Но всё равно спасибо, статья презабавная и реально полезная, если игнорировать твои выводы. Без обид.
alex0x08 Автор
21.08.2024 08:48А мы уже на ты?
Вроде на брудершафт не пили и лично не знакомы — точно есть повод? Или вас в лесу звери воспитывали?
Если по делу, то наверное последнее место куда имеет смысл заглядывать это OpenGL, тем более в статье про Go и WinAPI.
Vladicus
21.08.2024 08:48Да, да, да... Обиделся.
Именно поэтому и предложил глянуть на апи огла. Там ситуация куда как хуже чем на винапи, при этом, строение программы неотличимо, от винапишного. В общем, как обычно на Хабре. Увидел блестящую цацку и понёс в массы, ну и просто понёс. Ладно уж, оставайся в плену своих иллюзий.
vilgeforce
Ваш пример с SystemParametersInfoW на Go плох. Во-первых - волшебные константы, во-вторых в Visual Studio ровно то же самое будет в одну строку: просто вызов функции, без загрузки DLL и получения адреса функции.
alex0x08 Автор
Ваша версия?
vilgeforce
Моя, конечно
alex0x08 Автор
я имею ввиду хотелось бы увидеть вашу версию вызова
vilgeforce
#include <windows.h>
int main(void) {SystemParametersInfo(SPI_SETDESKWALLPAPER, 0, (void*)L"image.jpg", SPIF_UPDATEINIFILE);}
Собственно, весь вызов - одна строка
alex0x08 Автор
Так стоп )
Пропустил сразу этот момент: вы точно понимаете разницу между линковкой и динамической загрузкой библиотеки?
Какой вообще был смысл приводить пример на Си в статье по Golang?
vilgeforce
То есть это я(!) в статье привел официальный HelloWorld на сях и показал какой же он монструозный? Хехе...
Смысл в том, чтобы опровергнуть фразу "так красиво скрыты все скользкие моменты". Не красиво - из-за совершенно ненужной динамической загрузки. И не изящно из-за магических констант, о чем я сразу и написал
alex0x08 Автор
Если что в Go только такая загрузка и есть, что скорее хорошо чем наоборот, поскольку позволяет отлавливать ошибки и вообще как-то реагировать, например показать пользователю сообщение что "Библиотека не найдена".
Что касается "магических констант", то сие не более чем дело лично вашего вкуса, который навязывать окружающим (тем более так настойчиво) является плохим тоном.
vilgeforce
То есть, зная, что никаких иных вариантов, кроме динамической загрузки нет, вы сознательно написано про "так красиво"? К тому же я почему-то уверен что механизм импортов в скомпиленых файлах используется...
alex0x08 Автор
Да, ну потому что оно красивое, хотя-бы за счет динамической типизации.
Механизм импортов разумеется используется в нативных библиотеках, которые затем динамически загружаются в Go.
vilgeforce
Что касается динамической типизации - " сие не более чем дело лично вашего вкуса ". Про импорты вы пишете откровенную чушь - возьмите собранный EXE и посмотрите что он там импортирует. Исполняемый файл без импортов из Go? Нуну
alex0x08 Автор
У собранного бинарника на Go действительно нет внешних зависимостей, поэтому он такой большой. Или вы что-то другое имели ввиду?
vilgeforce
Ой, это же вы мне писали про разницу между линковкой и динамической загрузкой, да? Идите подучите матчасть на примере механизма импорта и экспорта функций в исполняемых файлах, для начала на винде
alex0x08 Автор
Ну понятно, дискуссии не получится в очередной раз.
Не пробовали оценить свое поведение со стороны? К чему оно приведет и что по-вашему будет дальше?
vilgeforce
Посмотрите на себя: попрекаете других тем, что делаете сами. Примеры этого вам уже привели, они остались без ответа.
Дискуссия была бы возможна, если бы именно ВЫ разбирались в механизмах ОС, о которой ведете речь. Поэтому идите учить матчасть. Потом, как выучите - смотреть в список импортов собранного EXE. Потом - жду комментариев "вы были правы".
alex0x08 Автор
Ладно, подскажу что будет дальше: я продолжу писать интересные статьи и общаться с умными и интересными читателями, а вас и все ваши сообщения буду просто игнорировать.
Жаль что на Хабре еще не приделали бан-листы, но что поделать.
Так что все чего вы добились это игнора, вне зависимости от вашего уровня профессонализма и талантов.
Всего хорошего.