10 January 2014

Chrome extension за выходные

JavaScriptProgrammingGoogle Chrome
From Sandbox
image

Проблема

Как обычно поздней ночью, садясь в автобус, я достал телефон, и пока набирал “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

Теперь предстояло всё это наполнить смыслом, то есть классами. Хром ограничивает возможности своих расширений и старательно расставляет везде грабли, поэтому Контроллер у меня сразу пошёл в фоновую страницу (background page), поскольку именно он разговаривает с сервером, а это можно только из фона. Об этом соответствующая запись в manifest.json:

  "background": {
    "scripts": ["lib/jquery-1.7.2.min.js", "src/DeviceStatus.js", "src/Controller.js"]
  }


После пары шишек стало ясно, что файлы должны перечисляться в порядке зависимости друг от друга (jQuery тут нужен для простоты ajax запросов и ещё пары мелочей).

Клиентская часть, как и ожидалось, получилась спартанская. Она появляется только при нажатии на иконку и задач у неё не много: добавить устройство и установить порог заряда.

Примерно так:
image

Для начала, надо через 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

Мяса наросло уже достаточно, чтобы подключать к Хрому и смотреть, что получается — очень помогает забыть про обед и продолжать работать. У Хрома есть для этого замечательная кнопка — Load Unpacked Extension, её я и направил в корень VS проекта. После пары корректировок manifest.json удалось получить кнопку в нужном месте и тестовую картинку.

Вдохновение подстёгнуто, дальше — модель. Она тоже очень простая — все свойства из класса DeviceStatus завёрнутые в observable и пара обработчиков событий — добавление устройства, удаление устройства и выбор активного. По ходу решили, что будем поддерживать несколько устройств, а какому соответствует иконка наверху — предоставим решать пользователю (отсюда разделение на PopupViewModel и DeviceStatusViewModel).

Теперь это уже выглядело так:
image

Настало время научить мой основной 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

С иконкой наверху тоже всё просто, Хром опять предоставил API, и при помощи нехитрых манипуляций с иконкой, подсказкой и текстом всё заработало как часы:
        if (updateIcon) {
            chrome.browserAction.setIcon({ path: path });
            chrome.browserAction.setTitle({ title: title });
            chrome.browserAction.setBadgeText({ text: "!" });
        }


После этого оставалось только поженить формат кода из андроид-приложения с расширением. Выбрали 12-символьный код во избежание повторений.

К полуночи в воскресенье первая версия была готова.

Потом было ещё 3 небольших апдейта с плюшками и инсектицидами, так что было бы не совсем честно утверждать, что только выходные и больше ничего не потрачено, но основная задача выполнена в срок.

Update:
если кто заинтересован написанием клиента под iOS, дайте знать
Tags: javascript chrome extension knockoutjs
Hubs: JavaScript Programming Google Chrome
+72
55.7k 260
Comments 66
Ads