Pull to refresh

QtDockTile — кроссплатформенное использование доков!

Reading time12 min
Views2.3K
Рассматривая современные тенденции в развитии десктопов сложно не обратить внимание на то, что идея дока становится все более и более популярной. Существует как минимум три популярные реализации этого принципа: Маковский док, таскбар из windiws 7 и launcher'ы из unity. К этому списку в kde 4.8 добавится ещё и icon tasks.
Одним словом, назревает необходимость в создании универсальной библиотеки для работы со всем этим многообразием.
Встречаем qtdocktile


Общее для всех доков



Прежде всего необходимо выделить список возможностей, которые являются общими для всех доков:
  1. Бейджи
  2. Индикатор прогресса
  3. Меню
  4. Сигнализация

Весь этот функционал является базовым и так или иначе поддерживается и в семёрке, и в макоси, и в убунте. Именно на основе него будет строится базовое API qtdocktile, а все платформозависимые расширения будут добавляться по мере развития библиотеки и не будут являться обязательными.

Архитектура библиотеки



Для максимальной гибкости и расширяемости я решил, что реализации каждого конкретного дока будут представлять из себя обычные Qt плагины — это позволяет добавлять поддержку новых API без перекомпиляции всей библиотеки, а в случае невозможности использовать ту или иную реализацию, плагин просто не запустится. Плагины загружает специальный синглтон-менеджер. Каждый плагин сообщает менеджеру, может ли он работать в данном окружении или нет, в результате чего менеджер может вызывать нужные методы лишь у тех плагинов, которые являются работоспособными в данной среде.
Пользователь же работает с простым классом QtDockTile, который является оберткой над менеджером. В результате чего можно безопасно создавать любое количество экземпляров QtDockTile — они не нарушат работу дока.
Для меню дока будет использоваться обычное Qtшное QMenu. Нужно лишь помнить об ограничениях, которые выставляет та или иная платформа.

Примерное использование библиотеки

m_tile->setMenu(ui->menu);

connect(ui->pushButton, SIGNAL(clicked()), m_tile, SLOT(alert()));
connect(ui->lineEdit, SIGNAL(textChanged(QString)), m_tile, SLOT(setBadge(QString)));
connect(ui->horizontalSlider, SIGNAL(valueChanged(int)), m_tile, SLOT(setProgress(int)));


Как можно заметить, оно очень простое! Но написать простое API это ещё полбеды, теперь нужно реализовать поддержку всех платформ:

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

Реализация плагина для Unity



Как ни странно, но для Unity получилась наиболее короткая и лаконичная реализация. Всё апи строится на отправки достаточно простых dbus сообщений:

void UnityLauncher::sendMessage(const QVariantMap &map)
{
	QDBusMessage message = QDBusMessage::createSignal(appUri(), "com.canonical.Unity.LauncherEntry", "Update");
	QVariantList args;
	args << appDesktopUri()
			<< map;
	message.setArguments(args);
	if (!QDBusConnection::sessionBus().send(message))
		qWarning("Unable to send message");
}


Где appUri — это уникальное название приложения, в данной реализации просто совпадающее с именем процесса, а appDesktopUri — это запись вида application://$appUri.desktop.
Для того, чтобы изменить значение на бейдже достаточно отправить такое сообщение:
	QVariantMap map;
	map.insert(QLatin1String("count"), count);
	map.insert(QLatin1String("count-visible"), count > 0);
	sendMessage(map);


Аналогично для индикатора прогресса и сигнализации, с меню чуть поинтереснее: необходимо воспользоваться классом DBusMenuExporter при создании передав ему appUri и указатель на QMenu. Вот и всё API, теперь давайте перечислим ограничения:

Ограничения Unity Launcher API


  1. Бейджи только цифровые и только больше 0. В противном случае выводится 0
  2. Экспортированное меню не показывает подменю, поэтому лучше их избегать.
  3. Если меню экспортируется еще и в appmenu, то оно не появится в доке
  4. В реализации DBusMenuExporter есть баг в результате чего состояние checked у меню инвертируется


Ну и последнее: для работы API обязательно необходимо наличие в /usr/share/applications .desktop значка для приложения. Кстати, Unity API позволяет добавлять в меню постоянные пункты, которые работают когда приложение не запущено, выглядит это примерно так:
X-Ayatana-Desktop-Shortcuts=NewWindow;

[NewWindow Shortcut Group]
Name=Open a New Window
Name[ast]=Abrir una ventana nueva
Name[bn]=Abrir una ventana nueva
Name[ca]=Obre una finestra nova
Name[da]=Åbn et nyt vindue
Name[de]=Ein neues Fenster öffnen
Name[es]=Abrir una ventana nueva
Name[fi]=Avaa uusi ikkuna
Name[fr]=Ouvrir une nouvelle fenêtre
Name[gl]=Abrir unha nova xanela
Name[he]=פתיחת חלון חדש
Name[hr]=Otvori novi prozor
Name[hu]=Új ablak nyitása
Name[it]=Apri una nuova finestra
Name[ja]=新しいウィンドウを開く
Name[ku]=Paceyeke nû veke
Name[lt]=Atverti naują langą
Name[nl]=Nieuw venster openen
Name[ro]=Deschide o fereastră nouă
Name[ru]=Открыть новое окно
Name[sv]=Öppna ett nytt fönster
Name[ug]=يېڭى كۆزنەك ئېچىش
Name[uk]=Відкрити нове вікно
Name[zh_CN]=新建窗口
Name[zh_TW]=開啟新視窗
Exec=firefox -new-window
TargetEnvironment=Unity


И на закуску пара скриншотов:


Unity:
image
KDE (Icon Tasks):
image

При написании плагина я пользовался наработками Torkve для qutIM'а.

Реализация плагина для Macos X



Тоже не возникло особенных сложностей, для экспорта меню в Qt уже есть специальный метод, о нём уже много было сказано здесь.
Бейдж же элементарно средствами Cocoa устанавливается, нужно лишь QString преобразовать в NSString, послать сообщение доку и позаботится об очисте памяти.
    const char *utf8String = badge.toUtf8().constData();
    NSString *cocoaString = [[NSString alloc] initWithUTF8String:utf8String];
    [[NSApp dockTile] setBadgeLabel:cocoaString];
    [cocoaString release];

Чуть сложнее оказалось сделать индикатор прогресса: API дока не имеет встроенного метода, зато имеется метод для рисования своего изображения в иконке дока. Чтобы сильно не заморачиваться я просто взял реализацию индикатора из QtCreator'а, благо лицензия LGPL спокойно позволяет такой финт ушами.

Скриншот

image

Реализация для Windows 7 Taskbar'а



И напоследок самое вкусное! Если в других системах процесс написания плагинов прошёл более-менее гладко, то для самой популярной настольной операционки все оказалось далеко не таким безоблачным, пришлось про себя поминать разными нехорошими словами Билл Гейтса, Стива Балмера и безымянных программистов, которые бережно разложили различные грабли! По ходу написания в голове не раз возникали фразы must die, wtf и тому подобное вплоть до старого доброго windos.
Тут есть и странные нечитаемые типы типа LPCSTR вместо wchar_t * и венгерская нотация во все поля и великий и ужасный COM, одним словом, стиль кода просто ужасен. А ещё есть здесь проблема в ABI в результате которой невозможно прилинковать С++ библиотеку, собранную MS компилятором к коду, собираемому minGW. Ну и само API несколько странное из за чего пришлось идти на некоторые костыли. Плюс ко всему примеры jump lists'ов содержат использование библиотеки ATL, которая есть лишь в платной студии и нам ну совершенно не подходит по этой причине.
Для решения проблем с ABI мы с dtf решили сделать минимальную Си обёртку над COM API таскбара чтобы в будущем была возможность линковаться с ней динамически из любого компилятора.
API у нее получилось весьма простым, сама обертка не зависит от Qt и её можно использовать из чего угодно, хотя она написана и совершенно не в стиле winAPI.

...
EXPORT void setApplicationId(const wchar_t *appId);
EXPORT void setOverlayIcon(HWND winId, HICON icon, wchar_t *description = 0);
EXPORT void clearOverlayIcon(HWND winId);
EXPORT void setProgressValue(HWND winId, int percents);
EXPORT void setProgressState(HWND winId, ProgressState state);
...


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

//получаем указатель на таскбар
static ITaskbarList3 *windowsTaskBar()
{
	ITaskbarList3 *taskbar;
	if(S_OK != CoCreateInstance(CLSID_TaskbarList, 0, CLSCTX_INPROC_SERVER, IID_ITaskbarList3, (void**)&taskbar))
		return 0;
	return taskbar;
}
...
// устанавливаем значение
void setProgressValue(HWND winId, int progress)
{
	ITaskbarList3 *taskbar = windowsTaskBar();
	if (!taskbar)
		return;
	taskbar->HrInit();
	taskbar->SetProgressValue(winId, progress, 100);
	taskbar->SetProgressState(winId, progress ? TBPF_NORMAL : TBPF_NOPROGRESS);
	taskbar->Release();
}
// устанавливаем тип индикации
void setProgressState(HWND winId, ProgressState state)
{
	TBPFLAG flags;
	ITaskbarList3 *taskbar = windowsTaskBar();
	if (!taskbar)
		return;
	taskbar->HrInit();
	switch (state)	{
		default:
		case ProgressStateNone          : flags = TBPF_NOPROGRESS;    break;
		case ProgressStateNormal        : flags = TBPF_NORMAL;        break;
		case ProgressStatePaused        : flags = TBPF_PAUSED;        break;
		case ProgressStateError         : flags = TBPF_ERROR;         break;
		case ProgressStateIndeterminate : flags = TBPF_INDETERMINATE; break;
	}
	taskbar->SetProgressState(winId, flags);
	taskbar->Release();
}

Бейдж я реализовал через метод setOverlayIcon, а саму иконку я рисовал и превращал в HICON средствами Qt
QPixmap WindowsTaskBar::createBadge(const QString &badge) const
{
	QPixmap pixmap(overlayIconSize());
	QRect rect = pixmap.rect();
	rect.adjust(1, 1, -1, -1);
	pixmap.fill(Qt::transparent);

	QPainter painter(&pixmap);
	painter.setRenderHint(QPainter::Antialiasing);
	QPalette palette = window()->palette();
	painter.setBrush(palette.toolTipBase());

	QPen pen = painter.pen();
	pen.setColor(palette.color(QPalette::ToolTipText));
	painter.setPen(pen);

	QString label = QFontMetrics(painter.font()).elidedText(badge, Qt::ElideMiddle, rect.width());
	painter.drawRoundedRect(rect, 5, 5);
	painter.drawText(rect,
					 Qt::AlignCenter | Qt::TextSingleLine,
					 label);
	return pixmap;
}

В итоге в бейдж пока влазит лишь 2 символа. Размер иконки выставляется через QStyle::pixelMetrics, мной было выяснено, что другие реализации overlayIcon просто рисуют иконку 16х16 и не заботятся о dpi, поэтому у меня на мониторе иконка получается размазанной.
А теперь самое интересное — реализация jump lists'ов. Вот уж где старина Билли услышал заочно много ласковых слов в свой адрес!

Мытарство номер 1 — сериализация QAction'а с учетом ограничений API


У каждого действия есть название, команда, которая исполняется при нажатии на действие и опционально путь до иконки в формате ico и описание. Причем всё это нужно передавать в виде сишных wide char строк, а значит и самостоятельно следить за временем их жизни. Ну и конечно нужно как-то организовать обратный вызов, что тоже не очевидно, ибо нужно вызывать метод trigger у QAction'а, что тоже не выглядит простым на первый взгляд.
В нашу сишную обёртку мы будем передавать массив структур такого содержания:
struct ActionInfo
{
	const char *id;
	wchar_t *name;
	wchar_t *description;
	wchar_t *iconPath;
	ActionType type;
	void *data; //вот тут главная хитрость - через этот указатель мы будем реализовывать обратный вызов и заодно следить за временем жизни всех наших строк.
};

typedef void (*ActionInvoker)(void*); //указатель на функцию, аргументом её является тот самый void *data


Теперь давайте раскроем тайну void *data:
typedef QVector<ActionInfo> ActionInfoList; //будем использовать возможности С++, чтобы избежать ручного слежения за временем жизни нашего массива
typedef QVector<wchar_t> WCharArray; //аналогично для wchar_t *

static WCharArray toWCharArray(const QString &str)
{
	WCharArray array(str.length() + 1);
	str.toWCharArray(array.data());
	return array;
}

struct Data
{
	Data(QAction *action) : action(action), icon(action->icon()),
		id(QUuid::createUuid().toByteArray()),
		name(toWCharArray(action->text())),
		description(toWCharArray(action->toolTip())),
		iconPath(toWCharArray(icon.filePath()))
	{
	}
	QWeakPointer<QAction> action;
	TemporaryIcon icon;
	QByteArray id;
	WCharArray name;
	WCharArray description;
	WCharArray iconPath;
};

void invokeQAction(void *pointer)
{
	Data *data = reinterpret_cast<Data*>(pointer);
	if (data->action) {
		qDebug() << data->action.data();
		data->action.data()->trigger();
	}
}

Вот такая вот сериализация действий получилась. Я постарался свести количество ручных new и delete к минимуму — всё происходит автоматически. Именно такой подход является залогом того, что ваши волосы будут гладкими и шелковистыми!

Теперь давайте вспомним об ограничениях платформы и поймем, какие действия мы можем сериализовывать, а какие лучше проигнорировать. Итак в jump lists'ах нет подменю, нет здесь и disabled и checkable пунктов, в общее число пунктов ограничено 20ью. Зато есть разделители, получается что-то вот такое:
		if (!action->menu()
			 &&  action->isVisible()
			 &&  action->isEnabled()
			 && !action->isCheckable())
			list.append(serialize(action));
...

ActionInfo JumpListsMenuExporterPrivate::serialize(QAction *action)
{
	Data *data = new Data(action);
	ActionType type = action->isSeparator() ? ActionTypeSeparator
														 : ActionTypeNormal;
	ActionInfo info = {
		data->id.constData(),
		data->name.data(),
		data->description.data(),
		data->iconPath.data(),
		type,
		data
	};
	return info;
}

Для того, чтобы отображались иконки, пришлось создать свою реализацию временных файлов, QTemporaryFile не очень нам подходит, ибо монопольно влалеет файлом. Её отдельно я рассматривать не буду: там всё очень просто и понятно.

Мытарство номер 2 — заполнение jumpLists'ов


Чтобы заполнить jump lists'ы нужно вызвать метод beginList
void JumpListsManager::beginList()
{
	if (m_destList)
		return;

	ICustomDestinationList *list;
	HRESULT res = CoCreateInstance(CLSID_DestinationList, 0, CLSCTX_INPROC_SERVER, IID_ICustomDestinationList, (void**)&list);
	if (FAILED(res)) {
		return;
	}
	UINT maxSlots;
	m_destList = list;
	m_destList->SetAppID(m_appId);

	m_destList->BeginList(&maxSlots, IID_IObjectArray, (void**)&m_destListContent);
	m_destListContent->Release();

	IObjectArray *objArray;
	CoCreateInstance(CLSID_EnumerableObjectCollection, 0, CLSCTX_INPROC_SERVER, IID_IObjectArray, (void**)&objArray);
	objArray->QueryInterface(IID_IObjectCollection, (void**)&m_destListContent);
	objArray->Release();
}

Потом этот список заполнить
void JumpListsManager::addTask(ActionInfo *info)
{
	if (!m_destList)
		return;
	IShellLinkW *task;
	HRESULT res = CoCreateInstance(CLSID_ShellLink, 0, CLSCTX_INPROC_SERVER, IID_IShellLinkW, (void**)&task);
	if (FAILED(res))
		return;
	task->SetDescription(info->description);
	task->SetPath(L"rundll32.exe");
	task->SetArguments(makeArgs(info).c_str());
	if (info->iconPath)
		task->SetIconLocation(info->iconPath, 0);

	IPropertyStore *title;
	PROPVARIANT titlepv;
	res = task->QueryInterface(IID_IPropertyStore, (void**)&title);
	if (FAILED(res)) {
		task->Release();
		return;
	}
	InitPropVariantFromString(info->name, &titlepv);
	title->SetValue(PKEY_Title, titlepv);
	title->Commit();
	PropVariantClear(&titlepv);

	res = m_destListContent->AddObject(task);
	title->Release();
	task->Release();

	m_actionInfoMap.insert(std::make_pair(info->id, info)); //обратите внимание на этот словарик: в нем хранится соответствие между id и указателем на действие.
}
...
void JumpListsManager::addSeparator()
{
	IShellLinkW *separator;
	IPropertyStore *propStore;
	PROPVARIANT pv;
	HRESULT res = CoCreateInstance(CLSID_ShellLink, 0, CLSCTX_INPROC_SERVER, IID_IShellLinkW, (void**)&separator);
	if (FAILED(res))
		return;
	res = separator->QueryInterface(IID_IPropertyStore, (void**)&propStore);
	if (FAILED(res)) {
		separator->Release();
		return;
	}
	InitPropVariantFromBoolean(TRUE, &pv);
	propStore->SetValue(PKEY_AppUserModel_IsDestListSeparator, pv);
	PropVariantClear(&pv);
	propStore->Commit();
	propStore->Release();
	res = m_destListContent->AddObject(separator);
	separator->Release();
}

И вызвать метод commitList
void JumpListsManager::commitList()
{
	if (!m_destList)
		return;

	m_destList->AddUserTasks(m_destListContent);
	m_destList->CommitList();
	m_destList->Release();
	m_destListContent->Release();
	m_destList = 0;
	m_destListContent = 0;
}

Многословно, не находите? Но увы, придется стиснуть зубы и продолжить строчить сотни строк кода, иначе работать ничего не будет, но мы же настоящие мужики и трудностей не боимся? А раз не боимся, то давайте реализуем обратный вызов!

Мытарство номер 3 — Реализация обратного вызова


Итак, что мы имеем? Активация пункта в jumpList'е вызывает команду с некоторым набором аргументов. Но как же нам через неё сказать, что мы хотим найти actionInfo с определенным id и совершить обратный вызов?
Мы с dtf долго думали над этим и он предложил сделать всё через rundll, который способен вызвать определённый метод из библиотеки с заданными аргументами.
В результате родился метод, который принимает id действия, открывает сокет на 42042 порту и передает в него полученное id, а библиотека слушает этот сокет и получив id спокойно делает обратный вызов и наш искомый QAction вызывается!
std::wstring JumpListsManager::makeArgs(ActionInfo *info)
{
	std::wstring args = m_wrapperPath;
#ifdef _WIN64
	args += L",_RundllCallback@28 "; // WARNING: TEST ME! // ptr×3 + int
#else
	args += L",_RundllCallback@16 ";
#endif

	// Convert to a wchar_t*
	size_t origsize = strlen(info->id) + 1;
	const size_t newsize = 64;
	size_t convertedChars = 0;
	wchar_t buffer[newsize];
	mbstowcs_s(&convertedChars, buffer, origsize, info->id, _TRUNCATE);
	args += buffer;
	return args;
}

И последний метод: реализация функции, которую вызывает rundll
EXPORT void CALLBACK
RundllCallback(HWND hwnd, HINSTANCE hinst, LPSTR cmdLine, int cmdShow);

void CALLBACK RundllCallback(HWND, HINSTANCE, LPSTR cmdLine, int)
{
	WSADATA wsaData;
	WSAStartup(MAKEWORD(2, 2), &wsaData);

	SOCKET sk;
	sk = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	if (sk == INVALID_SOCKET) {
		WSACleanup();
		return;
	}

	sockaddr_in sai;
	sai.sin_family      = AF_INET;
	sai.sin_addr.s_addr = inet_addr("127.0.0.1");
	sai.sin_port        = htons(Handler::port);

	if (connect(sk, reinterpret_cast<SOCKADDR*>(&sai), sizeof(sai)) == SOCKET_ERROR) {
		WSACleanup();
		return;
	}

	std::string cmd = cmdLine;
	send(sk, cmd.c_str(), cmd.size(), 0);
	closesocket(sk);
	WSACleanup();
}


Всё, кода на сегодня хватит, можно вздохнуть спокойно, давайте подведем итоги:

Ограничения в windows реализации


  1. Только два знака в бейдже
  2. В результате наших манипуляций пропадают последние файлы из jump lists'ов
  3. Не поддерживаются действия — переключатели и неактивные действия
  4. Не поддерживаются подменю


Скриншот:

image

Заключение



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

И ещё пара замечаний:
  • Корректная работа дока возможна лишь в случае single application, используйте док совместно с Qt Single Application или другими подобными средствами
  • В бейджах лучше использовать положительные числа меньше 100

В остальных случаях что-то будет доступно не во всех платформах. В принципе, это не смертельно, но нужно помнить об этом!
Спасибо Torkve за помощь в реализации Unity плагина, dtf за огромную помощь в реализации Windows плагина и разработчиков QtCreator'а за помощь в реализации Macos X версии.
Исходный код можно получить на github'е. Исправления и улучшения приветствуются.
ЗЫ
Есть ли желающие реализовать Dockmanager API?
Tags:
Hubs:
+38
Comments14

Articles

Change theme settings