golang

«Бобер выдыхай»: Go, WinAPI и ассемблер

  • четверг, 22 августа 2024 г. в 00:00:07
https://habr.com/ru/articles/837454/

Что первым приходит в голову разработчика при слове «Go»? Google и микросервисы? Я тоже так думал, но реальность оказалась значительно интересней.

Gopher - маскот Golang на самом деле никакой не бобер а целый отдельный вид, у нас такие не живут.
Gopher — маскот Golang на самом деле никакой не бобер а целый отдельный вид, у нас такие не живут.

Волшебный мир Windows

Эта статья родилась внезапно — из профессионального спора о реалиях и возможностях языка Go, которые как оказалось выходят сильно далеко за рамки его традиционной сферы примененения.

Немного матчасти для тех кто не знает об этом языке:

Go (часто также golang) — компилируемый многопоточный язык программирования, разработанный внутри компании Google[11]. Разработка Go началась в сентябре 2007 года, его непосредственным проектированием занимались Роберт Гризмер, Роб Пайк и Кен Томпсон[12], занимавшиеся до этого проектом разработки операционной системы Inferno. Официально язык был представлен в ноябре 2009 года.

Я как и наверное большинство разработчиков считал Golang всего лишь новомодной корпоративной игрушкой, призванной подсадить широкие программисткие массы на очередную технологию «корпорации добра» — создавался этот язык внутри Гугла и для задач Гугла, которые разумеется сильно отличаются от обывательских.

Поэтому когда мне показали работу Golang с WinAPI «из коробки» я был сильно удивлен — в более серьезных языках вроде C/C++ работа c внутренностями Windows всегда выглядела куда более монструозной. Так и родилась эта замечательная статья.

Что мы будем в этот раз творить:

Desktop-приложение с настоящим интерфейсом, с учетом реалий Windows, которое запустит встроенный вебсервер, с методом REST API на ассемблере.

Еще будет загрузка графического файла и установка его в качестве обоев — через WinAPI.

Плюс небольшой обход файрвола, чтобы не показывался вот этот раздражающий экран с предупреждением:

Он всегда меня бесил, а то что меня бесит — я отрываю отключаю.
Он всегда меня бесил, а то что меня бесит — я отрываю отключаю.

Надеюсь описанное в статье удивит даже опытных разработчиков на Golang.

Собственно вот:

Обратите внимание на отключенные кнопки «закрыть» и «развернуть» — даже это оказалось не так просто сделать на чистом WinAPI
Обратите внимание на отключенные кнопки «закрыть» и «развернуть» — даже это оказалось не так просто сделать на чистом WinAPI

А так выглядит работа с системным треем:

По клику происходит фокусировка на основном окне приложения
По клику происходит фокусировка на основном окне приложения

Еще у нас будет стандартный модальный диалог:

Подтверждение выхода, всего лишь.
Подтверждение выхода, всего лишь.

И встроенный веб-сервер, с веб-интерфейсом:

Если немного подумать, то окажется что тут много всего интересного и все оно описано ниже в статье.
Если немного подумать, то окажется что тут много всего интересного и все оно описано ниже в статье.

Ну и по традиции весь проект целиком выложен на Github.

Сборка и запуск

Начну с банального — как всю эту радость собрать и запустить.

Первым делом разумеется надо скачать и установить Go:

Для Windows уже давно существуют готовые официальные сборки Golang, даже с инсталлятором.

Взять можно с официального сайта Golang, вот тут.

Я использовал последнюю на момент написания статьи версию 1.22.5, но язык столь бурно развивается, что не удивлюсь если выйдет более новая версия еще до завершения статьи.

Разработка проекта происходила в Visual Studio Code, который давно и официально поддерживает Go:

Открытый проект в Visual Studio Code с установленным плагином для Golang
Открытый проект в Visual Studio Code с установленным плагином для Golang

Теперь самое интересное:

для сборки проекта использовались не обычные Makefile и не шелл-скрипты — так характерные для проектов на «гошечке», а целая отдельная внешняя система сборки — Magefile.

Ставится она множеством разных способов, я использовал вот такой:

git clone https://github.com/magefile/mage
cd mage
go run bootstrap.go

После установки в окружении появляется бинарник mage, отвечающий за сборку:

Mage это на самом деле mage.exe (в Windows разумеется)
Mage это на самом деле mage.exe (в Windows разумеется)

Забираем проект:

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

которым изменится значение глобальной переменной — флагом отладочного режима, который в свою очередь немного влияет на поведение программы.

Теперь начинаем разбираться как оно все работает.

Невероятный факт № 5668 : не каждый Windows-программист знает как скомпилировать программу из консоли.
Невероятный факт № 5668 : не каждый Windows-программист знает как скомпилировать программу из консоли.

Приложение 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 файл.

Так выглядит "официальный Hello World" на C++ и WinAPI
Так выглядит «официальный Hello World» на C++ и WinAPI

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

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:

Собственно тут показаны все основные радости Windigo, доступные без долгих часов камлания над документацией
Собственно тут показаны все основные радости 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 11
Вот так это выглядит в Windows 11

Работа с системным треем

Разумеется есть способ проще:

взять одну из готовых библиотек, тем более что есть универсальные — сразу для 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, позволяющее пропустить вот такое откровенно дурацкое подтверждение:

Был добавлен еще в Windows 7 с официальной целью: выбешивать пользователей.
Был добавлен еще в Windows 7 с официальной целью: выбешивать пользователей.

Как и в случае с интерфейсом, было принято волевое решение использовать готовый пакет с биндингами:

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.

Да, как только начинается большая криптография и множественные вычисления на слабом железе — сразу с дальней полки достается пыльная книга по ассемблеру.

Разумеется не было открытием что язык, с самого своего начала имевший компилятор в нативный код умеет вызывать вставки на ассемблере. Открытием была легкость и простота с которой это делается.

Не задумывались почему маскоты Go и Plan 9 так похожи?
Не задумывались почему маскоты Go и Plan 9 так похожи?

Еще одним открытием оказались торчащие уши 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

Мы небольшая команда ветеранов ИТ‑индустрии, создаем и дорабатываем самое разнообразное программное обеспечение, наш софт автоматизирует бизнес‑процессы на трех континентах, в самых разных отраслях и условиях.

Оживляем давно умершеечиним никогда не работавшее и создаем невозможное — затем рассказываем об этом в своих статьях.