![image](https://habrastorage.org/getpro/habr/post_images/b4d/882/272/b4d882272171d9aa49c846a8a0140d70.png)
Проблема
Как обычно поздней ночью, садясь в автобус, я достал телефон, и пока набирал “habr…” он отрубился. Я вслух подумал: “А раньше не мог сказать?”, немного пожалел, что телефоны редко пищат, пока разряжаются. А потом…
Потом мы с приятелем решили подойти к вопросу по-мужски. Он написал программулину для андроида, а я расширил Хром. О последнем и пойдёт речь.
Задача
Итак, идея: андроид-приложение наблюдает за состоянием аккумулятора и периодически уведомляет сервер об уровне заряда. Причём делает это как-нибудь по-умному, чтобы заряд от этого не пострадал. Хром-расширение выставляет свою иконку в специально отведённом месте, иконка показывает заряд батарейки андроида и всячески привлекает внимание, если она совсем почти разряжена. А чтобы всё не казалось слишком простым, реализовать идею надо было за одни выходные. В противном случае баланс ценность/усилия вываливался за рамки бесплатного приложения.
Таким образом, выбор подхода к задаче оказался даже важнее скорости десятипальцевой печати.
За дело.
Решение
Расширять Хром оказалось не так уж сложно, но надо было всё сделать как можно быстрее. Выходные одни, а хотелок много. Для ускорения разработки хотелось не париться с обработкой событий и обновлением HTML и knockout тут подошёл лучше всех. А поскольку вся логика для Хром-расширения пишется на javascript, избежать многих граблей помогает typescript. Эти двое из ларца сразу пошли в оборот. С технологиями опередлился, теперь самое главное. Полезной нагрузки много не ожидалось, но огород нагородить из спагетти можно и здесь. Самый простой и надёжный вариант виделся в паттерне MVC. C knockout он немного не вязался, тот сам по себе MVVM, но ясно было, что управление будет вестись с фоновой страницы (об этом позже) на которой knockout не будет. Выбор сделан. Вперёд — кодить. Времени осталось уже на час меньше.
Исполнение
Начал я с создания нового проекта в Visual Studio 2013, для простоты выбрал ASP.NET Empty application. Видел, отцы тут используют более подходящие шаблон — HTML Application with TypeScript — у меня его в наличии не было, поэтому пришлось попотеть с настройкой typescript compile-on-save
Прокачал проект минимумом библиотек и их typescript декларациями. Очень помог дружище Борис Янков с шикарным набором typescript-деклараций, хотя некоторые пришлось допиливать самому по ходу дела.
Далее компоненты MVC:
M: модели сделал две, одна на весь список, одна на отдельное андроид-устройство (PopupViewModel и DeviceStatusViewModel). По сути они ViewModel, но далее по тексту просто Модель
V: представление на 2 разбивать больше мороки, чем пользы, создал только popup.html
С: ну он так и назвался — Controller
Ещё сделал DeviceStatus — это структура, пересылаемая между расширением и сервером, а заодно и между Контроллером и Моделью.
Суперважный файл — manifest.json, этот нужен Хрому для разпознавания расширения.
Ещё пара манипуляций, и получилась вот такая картинка:
![image](https://habrastorage.org/getpro/habr/post_images/66a/639/d97/66a639d975d769cd769489808bf7023c.png)
Теперь предстояло всё это наполнить смыслом, то есть классами. Хром ограничивает возможности своих расширений и старательно расставляет везде грабли, поэтому Контроллер у меня сразу пошёл в фоновую страницу (background page), поскольку именно он разговаривает с сервером, а это можно только из фона. Об этом соответствующая запись в manifest.json:
"background": {
"scripts": ["lib/jquery-1.7.2.min.js", "src/DeviceStatus.js", "src/Controller.js"]
}
После пары шишек стало ясно, что файлы должны перечисляться в порядке зависимости друг от друга (jQuery тут нужен для простоты ajax запросов и ещё пары мелочей).
Клиентская часть, как и ожидалось, получилась спартанская. Она появляется только при нажатии на иконку и задач у неё не много: добавить устройство и установить порог заряда.
Примерно так:
![image](https://habrastorage.org/getpro/habr/post_images/207/eb1/584/207eb1584070f932b6a90529d29ac39f.png)
Для начала, надо через manifest.json дать Хрому знать что и когда открывать:
"browser_action": {
"default_icon": "images/icon.png",
"default_popup": "views/popup.html"
},
Код представления (popup.html) предельно простой — немного HTML и атрибуты привязки к модели knockout. Занудные подробности опустил, их можно и так посмотреть на живом примере. Одно важно здесь — просто так Хром не работает с knockout, ему нужно дать полномочий через manifest.json:
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
вот этот unsafe-eval и нужен для knockout. Подробности здесь.
![image](https://habrastorage.org/getpro/habr/post_images/d8c/43b/a4c/d8c43ba4c4d6442ecc0bc2befab90994.png)
Мяса наросло уже достаточно, чтобы подключать к Хрому и смотреть, что получается — очень помогает забыть про обед и продолжать работать. У Хрома есть для этого замечательная кнопка — Load Unpacked Extension, её я и направил в корень VS проекта. После пары корректировок manifest.json удалось получить кнопку в нужном месте и тестовую картинку.
Вдохновение подстёгнуто, дальше — модель. Она тоже очень простая — все свойства из класса DeviceStatus завёрнутые в observable и пара обработчиков событий — добавление устройства, удаление устройства и выбор активного. По ходу решили, что будем поддерживать несколько устройств, а какому соответствует иконка наверху — предоставим решать пользователю (отсюда разделение на PopupViewModel и DeviceStatusViewModel).
Теперь это уже выглядело так:
![image](https://habrastorage.org/getpro/habr/post_images/239/853/4e0/2398534e0eb2fa8254dcef23c82a4afe.png)
Настало время научить мой основной UI получать данные. Очередные грабли Хром подложил в виде невозможности прямо вызывать методы фоновой страницы (у меня там Контроллер сидит). Всё общение проходит через соответствующий API Хрома и только асинхронно.
Вызовы выглядят примерно так:
chrome.extension.sendMessage({ method: "GetAllDevices" }, (allDevices: DeviceStatus[]) => {
if (!allDevices || allDevices.length == 0) {
console.info("Received empty device list");
return;
}
...
});
А на фоновой странице небольшой велосипед раскидывает эти сообщения по методам Контроллера:
var server = new Controller();
chrome.extension.onMessage.addListener(
function (request: any, sender: any, sendResponse: (result: any) => void ) {
return server[request.method].call(server, request.data, sendResponse);
});
Вызовов много не понадобилось:
- дай все зарегистрированные устройства (сразу со статусами)
- добавь новое устройство, он же — показывай это устройство на иконке
- удали устройство
- сохрани порог чувствительности для устройства
Обязанности Контроллера немного шире:
- как проснулся (Хром стартовал), прочитать все зарегистрированные устройства и запросить их статус на сервере
- периодически запрашивать статус всех устройств и обновлять иконку в соответствии со статусом активного устройства
- реагировать на вызовы UI
- если у какого-нибудь устройства заряд ниже заданного предела — дать знать через иконку и desktop notifications, на случай, если юзер вдруг переключился на Firefox/CounterStrike/Visual Studio.
- при каждом удобном случае сохранять состояние где-то, где можно прочитать, проснувшись
Естественно, с первого раза ничего не заработало и пришлось отлаживать код. Причём одновременно на фоновой странице и в клиентской части. С клиентской частью всё просто — правой кнопкой по кнопке расширения и “Inspect popup”, тут и отладчик, и DOM можно посмотреть, и что очень важно — typescript мне породил кучу *.map файлов, а Хром их сам подхватил, и в Хромовой консоли я отлаживал typescript, а не javascript. Мне это очень понравилось, за исключением одного момента — typescript предусмотрительно создаёт переменную _this и записывает в неё ссылку на this. Это позволяет без потерь работать в рамках объектов, но отладчик этого не знал и часто выдавал всякую чушь, когда я пытался смотреть значения переменных. После нескольких шишек я понял, что во время отладки все this надо менять на _this, чтобы увидеть правдивое значение, тогда всё встало на свои места.
Теперь фоновая страница. По началу пользовался console.log, но очень скоро его стало не хватать, и тут обнаружилась очень полезная ссылка — на странице расширений, как оказалось (и почему не часом раньше?) есть такая строчка:
Inspect views: background page,
по ней-то и открывался отладчик фона.
Сохранять и читать состояние оказалось довольно просто, Хром предоставил API, и даже утверждает, что оно будет синхронизироваться вместе с остальными данными между разными Хромами, если настроено. Синхронизацию не проверял. А выглядит это так:
private ReadState(callback: () => void): void {
this.devices = [];
chrome.storage.sync.get(["aid", "ds"], (storedValues: any) => {
if (storedValues.aid != null && storedValues.aid != "")
this.deviceId = storedValues.aid;
var devicesJson = storedValues.ds;
this.devices = JSON.parse(devicesJson);
callback();
});
}
private WriteState(): void {
chrome.storage.sync.set({ "aid": this.deviceId, "ds": JSON.stringify(this.devices) });
}
Запрос статуса с сервера — совершенно обычный ajax:
private RequestStatus(data: any, successCallback: (status: DeviceStatus) => void , errorCallback: (error: string) => void ): void {
$.ajax({
url: "https://localhost/cbs/" + data.deviceId,
type: "GET",
success: successCallback,
error: (xhr, error) => {
console.error(error);
errorCallback(error);
}
});
}
Одни из самых изощрённых граблей на пути встретились при отладке привязки модели к представлению. Knockout никак не хотел регистрировать обработчик события, вместо этого прямо на месте его и вызывал. Обнаружить проблему помогло другое Хром-расширение — knockout context debugger.
И с уведомлениями пришлось попотеть. Хром предоставляет API для уведомлений, но с одним существенным ограничением — оно провисит на экране только 5 секунд, после чего от него почти ничего не останется, только маленький звоночек в system tray. И это никак не настраивается. После нескольких неудачных попыток проблема решилась через webkit notifications.
var n = webkitNotifications.createNotification(opt.iconUrl, opt.title, opt.message);
n.onclose = () => { ... };
n.show();
И тогда Хром стал напоминать о батарейке примерно так:
![image](https://habrastorage.org/getpro/habr/post_images/03c/f1d/9d7/03cf1d9d7c827e16bbac09507db86130.png)
С иконкой наверху тоже всё просто, Хром опять предоставил API, и при помощи нехитрых манипуляций с иконкой, подсказкой и текстом всё заработало как часы:
if (updateIcon) {
chrome.browserAction.setIcon({ path: path });
chrome.browserAction.setTitle({ title: title });
chrome.browserAction.setBadgeText({ text: "!" });
}
После этого оставалось только поженить формат кода из андроид-приложения с расширением. Выбрали 12-символьный код во избежание повторений.
К полуночи в воскресенье первая версия была готова.
Потом было ещё 3 небольших апдейта с плюшками и инсектицидами, так что было бы не совсем честно утверждать, что только выходные и больше ничего не потрачено, но основная задача выполнена в срок.
Update:
если кто заинтересован написанием клиента под iOS, дайте знать