Отличия в адаптации сайта и AJAX веб-приложения для iOS

    Есть сейчас такая тенденция — делать в сайтах поддержку планшетов iPad и других устройств на iOS: iPhone, iPod. Но если для сайтов это достаточно просто, при хорошей верстке, можно добавить пару тегов в head и готово, то для веб-приложений, где есть сессии с использованием Cookies, все обстоит сложнее и есть подводные камни. Итак, возможно, еще не все знают, что в мобильном Safari можно нажать кнопку меню (со стрелкой, как на рисунке) и выбрать там «Добавить в Домой» / «Add to Home Screen», тогда для сайта появится иконка на рабочем столе. Но иконка будет просто запускать Safari с этим сайтом, а вот если добавить пару известных тегов (см. ниже), то все элементы управления Safar будут скрыты и приложение будет работать на полный экран, как обычные нативные приложения iOS. Так вот основная выявленная проблема в том, что в этом режиме сессия все время сбрасывается. Стоит переключится на другое приложение или рабочий стол, даже просто перейти по ссылке, и опять вернуться в веб-приложение, как страница перегрузится и сессионной Cookie уже не будет, нужно логиниться заново. Эту проблему то мы и решим.

    По хорошему, полноэкранный режим доступен только для полностью динамических AJAX веб-приложений, которые не перегружают экран целиком и не переходят по ссылкам даже в рамках своего домена. Если же адаптируется обычный сайт, где навигация происходит при смене URL и перегрузке страниц, то и fullscreen не доступен и куки не исчезают.

    Рецепт для простого сайта


    Решение это известно, я приведу его только потому, что ниже дополню его отличиями для веб-приложения.
    Тут документация по дополнительным мета-тегам Safari, а вот нужные нам мета-теги для вставки в head:

    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
    <link rel="apple-touch-icon-precomposed" href="/favicon.png">
    

    Картинка /favicon.png станет иконкой на рабочем столе. Про размеры иконок подробнее написано в документации, их может быть несколько, например.

    Полноэкранный режим


    Для перехода приложения в полноэкранный режим, присовокупим к тем двум тегам еще два:

    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="apple-mobile-web-app-status-bar-style" content="black" />
    

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

    Решение для WebApplication


    Как я вскользь упоминал, для веб-приложений нужно выдержать дополнительные условия: приложение не должно переходить по ссылкам a href, это приводит к открытию ссылок в браузере и выходу из фулскрин режима. Но вот делать window.location.reload(true); можно и даже window.location = "/demo/path"; вполне разрешен из JavaScript. При этих переходах кукизы не теряются и все хорошо.

    Следующий код позволит сохранить сессионный cookie в localStorage, и когда кукиз будет потерян при переходе между приложениями в iOS, то этот же код восстановить из кукиз и перегрузит страницу, чтобы сервер отдал ее в том виде, как должен получить залогиненый пользователь.

    function PersistCookie(SessionCookieName) {
      if (localStorage && (
        navigator.userAgent.match(/iPhone/i) ||
        navigator.userAgent.match(/iPod/i) ||
        navigator.userAgent.match(/iPad/i)
      )) {
        var CookieSession = document.cookie.match(new RegExp(SessionCookieName + "=[^;]+"));
        var LocalSession = localStorage.getItem(SessionCookieName);
        if (CookieSession) {
          CookieSession = CookieSession[0].replace(SessionCookieName + "=", "");
          if (LocalSession!=CookieSession) {
            localStorage.setItem(SessionCookieName, CookieSession);
          }
        } else if (LocalSession && LocalSession!=CookieSession) {
          document.cookie = SessionCookieName + "=" + LocalSession + "; path=/";
          window.location.reload(true);
        }
      }
    }
    

    Как видно из кода, у нас есть два места хранения сессионной переменной: document.cookie и localStorage, мы читаем из обоих, а пишем туда, где кода небыло. В случае, если код есть в обоих местах, то предпочтение отдается document.cookie, т.к. может так случиться, что сервер заменит сессионную переменную и нам ее нужно записать поверх той, что уже есть в localStorage. Пример вызова: PersistCookie(«SID»); В параметрах передается имя сессионной куки. Вызов нужно делать при загрузке страницы, но оборачивать в событие «onload» или в jQuery.ready() не обязательно. Для PHP имя сессионной куки «PHPSESSID», для ASP.NET «ASP.NET_SessionId» и т.д. но может меняться в настройках сервера или программно. При отлогинивании пользователя нужно не забыть сделать if (localStorage) localStorage.clear(); чтобы кукиз не вернулся. Еще можно отключить проверку navigator.userAgent, чтобы код работал не только в iOS, но я не исследовал, будет ли это полезно или вредно.

    P.S. Вообще, я нашел описание проблемы с кукизами в англоязычных форумах, и один робкий совет: устанавливать время жизни сессионного куки со стороны сервера. Не знаю, почему такое советовали, я пробовал это делать, совет не работает, возможно, он работал на каких-то старых версиях iOS или человеку только показалось, что этот метод сработал. Вообще, сессионные куки не должны иметь время жизни, т.е. поля Expires по спецификации у них нет, иначе они перестают быть сессионными.

    UPD: Обнаружилась приятная особенность, localStorage сквозной для всего домена, то есть, залогинившись в Safari, сессия распространяется на установленное «псевдо-приложение» и наоборот, если в приложении залогиниться, то потом в сафари кукиз добавляется, если страницу обновить.

    UPD2: Есть и неприятная особенность, каждый раз после возвращения к «псевдо-приложению», кроме сброса кукизов еще и страница перегружается, то есть, если выдать форму и пользователь ее начинает заполнять, потом переключился куда-то, вернулся и все пропало, и форма и все, что ввел. Так что, еще до поста нужно все сохранять в localStorage. Скорее всего, нужно сделать универсальное решение для сохранения форм с любыми полями, и вообще, сохранения «состояния» приложения на момент переключения, чтобы восстановить что там было. Состояние же может содержать состояние навигации внутри приложения, состояние контролов (например закладок, списков), состояние прокрутки, состояние динамических изменений html и css на странице. Все же сбрасывается на пол пути. Кстати, страница при сбросе не перегружается с сервера, а берется из кеша.

    UPD3: Для тех, кто хочет минимальными усилиями сделать обычный сайт псевдо-приложением в фулскрин режиме, но не имеет желания переписывать все на AJAX, можно перехватывать все ссылки и делать переходы между страницами через window.location. При этом, как уже говорилось, Safari не выбьет вас в режим браузера, если только ссылка не будет вести на другой домен. Вот решение на jQuery:

    $('a').live('click', function(e) { e.preventDefault(); window.location = $(this).attr('href'); });
    

    Но остается проблема со сбросом сайта на первую страницу при переключении между приложениями. Это лечится так же, как и с кукизами — сохраняем в localStorage. Конечно же, нужно определаять, куда ведут ссылки и сохранять в localStorage только ссылки в пределах нашего домена, все другие и так будут открываться в Safari. Вот все наработки собраны в расширение для jQuery:

    (function($) {
    
    $.platform = {
      iPhone: navigator.userAgent.match(/iPhone/i),
      iPod: navigator.userAgent.match(/iPod/i),
      iPad: navigator.userAgent.match(/iPad/i),
      Android: navigator.userAgent.match(/Android/i)
    };
    
    $.platform.iOS = $.platform.iPhone || $.platform.iPod || $.platform.iPad;
    $.platform.Mobile = $.platform.iOS || $.platform.Android;
    
    $.extend({
    
      fixLinks: function(persist) {
        if ($.platform.iOS) {
          if (persist == null) persist = true;
          persist = persist && localStorage;
          if (persist) {
            var CurrentLocation = window.location.pathname + window.location.search;
          	var StoredLocation = localStorage.getItem("location");
            if (StoredLocation && StoredLocation !== CurrentLocation) {
              window.location = StoredLocation;
            }
          }
          $('a').live('click',function(e) {
            e.preventDefault();
            if (persist && this.host === window.location.host)
            localStorage.setItem("location", this.pathname + this.search);
            window.location = this.href;
          });
        }
      },
    
      fixCookie: function (SessionCookieName) {
        if (localStorage && $.platform.iOS) {
          var CookieSession = document.cookie.match(new RegExp(SessionCookieName + "=[^;]+"));
          var LocalSession = localStorage.getItem(SessionCookieName);
          if (CookieSession) {
            CookieSession = CookieSession[0].replace(SessionCookieName + "=", "");
            if (LocalSession!=CookieSession) {
              localStorage.setItem(SessionCookieName,CookieSession);
            }
          } else if (LocalSession && LocalSession !== CookieSession) {
            document.cookie = SessionCookieName + "=" + LocalSession + "; path=/";
            window.location.reload(true);
          }
        }
      }
    
    });
    
    })( jQuery );
    

    Использовать расширение очень просто, подключите js файл с библиотекой и при загрузке страницы вставьте вызовы:
    • Исправить проблему с нежелательным выходом в Safari по ссылкам для iOS полноэкранного приложения и сохранять/восстанавливать текущий URL в пределах домена в localStorage: $.fixLinks();
    • Исправить проблему с выходом в Safari для iOS, но не запоминать URL в localStorage: $.fixLinks(false);
    • Исправить проблему со сбросом сессионных Cookie: $.fixCookie(«SID»); где «SID» имя сессионной куки.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 29

      –2
      кстати у мобильного сафари на айфоне есть еще неприятная штука — до первого «поста» браузер не принимает никаких кук вообще, и точно также не работает аякс (даже не делает обращений)
        0
        А на айпеды это не распространяется? Не совсем понятно, как это ajax не работает до поста? Первый запрос в приложении должен быть POST а не GET или что имеется в виду?
          –1
          на все распространяется, сафари под иос один
          да, сафари дает работать с сервером только после того как сделать на него осмысленный пост. причем если на настольном сафари можно сделать скрытый фейковый пост через ручную форму (вместо пользователя) то на мобильном такое не катит

          до первого такого взаимодействия с сайтом с него не принимаются куки а обращения к аяксу (я через jQuery только пробовал) оканчиваются ошибкой (на сервер не приходит ничего даже)

          по крайней мере у меня так, может где-то чего недопонял, но сам столкнулся
            +1
            Я все еще не понимаю, что такое «осмысленный пост» и чем он отличается от ajax поста, сделанного через тот же jQuery. Приведите пример, а то у меня все работает, не могу понять проблему.
              0
              на странице форма с кнопкой сабмит и методом пост. после клика тот сайт на который ведет форма становится «нормальным», без ограничений. до этого поста аякс на домен куда эта форма ведет оканчивается ошибкой

              у вас это может работает если вы на этот сайт как-либо до этого зашли что сафари пометил его как «нормальный»
                0
                Да нет, у меня вообще ни разу POST не происходит с полной перезагрузкой страницы. Login отправляется через AJAX POST с помощью jQuery. Если логин произошел, то дальше выполняю уже операции с сервером, требующие аутентификации. Предоставьте демо сюда, может чем помогу и я и люди.
                  0
                  Может Вы на iPhone пробуете? У меня только iPad3 и iPad2 под рукой.
                    0
                    я с этим столкнулся на иподе :)
                    может конечно там какой-то отсталый сафари, но т.к. он от версии иос зависит (5.1) то врядли
                      0
                      а, принципиальный момент — это в webkite интегрированном в приложение.
                      возможно для установленных ярлычком это все не актуально
                        0
                        А «webkite интегрированном» — это как? Для обычных сайтов AJAX же нормально работает и для установленных на рабочий стол фулскрин веб-приложений тоже все гуд.
                          0
                          Подтверждаю.
                0
                подтверждаю. с айфоном хрень. если слать аякс запросы через jquery, на сервак они доходят как OPTIONS вместо GET или POST. при чем нативные через XMLHttpRequest доходят нормально.
              0
              Синхронизация сессионной переменной cookies и localStorage — не очень хорошая идея, потому что пользователь может отвалиться по таймауту.

              Хочу немного внести ясности про «Псевдо-приложения» — это фактически закладка на страницу, вся механика используется Сафари (это про хранения куков и прочих)
              FullScreen по разному работает на iPhone и iPad: у iPad он может быть только, если открыть через «псевдо-приложение». А так как это просто страница в Сафари, то можно воспользоваться всей мощью HTML5 (включая offline web app), единственное нужно не забывать про ограниченность пространства под хранение cookies и localstorage.

              Про проблемы с кукисами: вы что то неправильно делаете, действительно кукисам нужно указывать срок годности, если вы не хотите их потерять после «выхода» из приложения или после «окончании» сессии (работает также как и Сафари). А самое главное уберите тег:
              <meta name="apple-mobile-web-app-capable" content="yes" />
              
              и должно все заработать.
                0
                Ясности не добавилось, «пользователь может отвалиться по таймауту» — это как? Имеется в виду, что сессия со стороны сервера прекратится или что?

                Я как раз и пишу про отличия в механике хранения кукизов «псевдо-приложений» и обычных «закладок на страницу». Механика меняется при добавлении этого самого apple-mobile-web-app-capable.

                И сессионные кукизы не имеют пол «Exires», я ссылку об этом прислал (см. ниже, нечаянно не туда постнул).
                  0
                  Да, имеется в виду, что сессия прекратится на стороне сервера.

                  А какие цели вы ставите? Избавится от адресной строки?

                  Разницы в механике между «псевдо-приложением» и закладкой в сафари нет. Есть только дополнительные теги в коде страницы. Ведь, чтобы создать «псевдо-приложение» вам нужно вначале зайти на этот сайт из сафари.

                  Почитайте статьи как можно сделать fullscreen средствами javascript. На stackoverflow достаточно много об этом написано. Я изменю свое утверждение — «Можно получить fullscreen не используя apple-mobile-web-app-capable. И даже не используя apple-touch-fullscreen.» И как я сказал (или имел ввиду) куки режутся именно из-за этого тэга.
                    0
                    Ну если сервер удалит сессию, т.е. не будет признавать идентификатор сессии, то клиент об этом узнает по его ответам и должен перелогиниться.

                    Как «можно получить fullscreen не используя apple-mobile-web-app-capable». Ссылку в студию.
                    Нужно чтобы приложение работало как нативное, т.е. запускалось по иконке на рабочем столе, открывалось в fullscreen, не масштабировалось тачем. Я такого не нашел.
                      0
                      Не обессудьте за грубость да поможет вам гугл

                      Также могу кинуть в личку ссылку на сайт, где это сделано.
                        0
                        Там везде по apple-mobile-web-app-capable и еще про window.scrollTo(0, 1); говорят, но я не нашел рабочего примера. Давайте точную ссылку, кусок кода или демонстранционный рабочий пример.
                          0
                          Из кода, предоставленного GeorgeKoraff, выделена идея динамического добавления тегов, но кукизы все еще сбрасываются. Продолжаем эксперименты над альтернативными методами. А вот динамическая вставка тегов, может кто-то тоже ведет эксперименты и это пригодится:
                          AddMeta("apple-mobile-web-app-capable", "yes");
                          AddMeta("apple-mobile-web-app-status-bar-style", "black");
                          AddMeta("viewport", "width=device-width; initial-scale=1.0; minimum-scale=1.0; maximum-scale=1.0; user-scalable=no;");
                          
                          function AddMeta(name, value) {
                          	var meta = document.createElement('meta');
                          	meta.name = name;
                          	meta.content = value;
                          	document.getElementsByTagName('head').item(0).appendChild(meta);
                          }
                          
                  0
                  Тут мне в твиттер написали, чтобы я вас спросил — есть ли на странице тег (если нет, то возможно поэтому OPTIONS и отправляется)?
                    0
                    *тэг
                    <base>
                      0
                      проясните связь тега base и OPTIONS запроса.
                        0
                        Лично я и сам не вкурил, посмотрим что человек, задавший вопрос, ответит.
                          0
                          Вот получил ответ:
                          OPTIONS отправляется при кросс-доменных запросах, поэтому браузеру нужно знать домен сайта до запроса.

                          И возможно браузер мог бы определить домен из тега base если по какой-то причине он не хранится с мета-данными ярлыка на homescreen

                          Кстати, я бы в случае OPTIONS запроса просто прологировал http заголовки, чтобы понять с какого домена запрос.

                          может у них ярлык для www.site.com, а запрос идет на site.com
                            0
                            в гугле тоже на это намекали. Наверное jquery как-то специфически URL допиливает. Надо будет потестировать.
                      0
                      Внимание: в статье появился UPD3, содержащий универсальное решение по адаптации в фулскрин режим обычных сайтов, которые содержат ссылки и перегружают страницы полностью, без AJAX.
                        +1
                        Выложил библиотеку MarcusAurelius на Nuget.
                        Библиотека доступна тут nuget.org/packages/Mobilization.
                        А также легко устанавливается командой PM> Install-Package Mobilization в Nuget Package Manager в Visual Studio.
                          0
                          Я понимаю, что поднимаю демона из могилы, но у меня есть несколько дополнений, которые я прочувствовал, когда делал Web App для KiDROM.RU:

                          1) запоминание текущего пути — это хорошо, но плохо работает со всем, что идёт не через ссылку (формы, например). В итоге: форму засабмитил, CurrentLocation != StoredLocation — редиректит на StoredLocation, который неправильный… В общем, было бы круто иметь базовый URL Web App (который был при сохранении), но я не знаю, как его получить, поэтому редирекчу на StoredLocation тогда и только тогда, когда CurrentLocation=/ и отличается от StoredLocation (как бы предполагая, но Web App сохраняют с корневой страницы). Если кто знает, как получить URL Web App — напишите!

                          2) то ли в PHP, то ли в Yii с какого-то момента для сессионных кук форcировали httpOnly=true — это значит, что они больше не видны в document.cookie. Тут варианта два: или в конфигах прописывать httpOnly=false, или делать свой get/set для кук и теребить их через ajax.

                          2.1) Yii при авторизации меняет SID — это нормально, такие моменты нужно учесть, чтобы сохранить возможность авторизироваться, и не сбрасывать новый правильный SID на тот, что в localStorage.

                          3) в алгоритме какая-то непонятная логика: если кука есть, сохраняем её в localStorage, если нет, только тогда ставим куку из LS. Суть в том, что при сбросе сессии (выключил/включил Web App) сразу будет новый SID, а в LS хранится старый SID. Кука есть = сохранить в LS новый SID, то есть никакого толка вообще. Короче, «else» лишний в коде.

                          4) ещё если делать для всех ссылок window.location = this.href, то будет выкидывать в Safari для энкоров на странице (href="#someID"), для таких случаев надо переделывать на window.location.hash = this.href.replace('#', '');

                          В целом, я пришёл к тому, что надо бы сделать со стороны сервера страничку, куда аяксом передавать SID из localStorage: если они совпадают, то вернуть false, если изменился, то вернуть новое значение в зависимости от того, какое из них (кука или localStorage) нужно использовать, и сохранить его в LS. При таком подходе можно сохранить httpOnly=true, и вообще не нужно ничего считывать из кук в JS, плюс получаем возможность контролировать какой именно SID поддерживать и сохранять везде (как раз тот случай, когда SID меняется правомерно, и отследить такие моменты со стороны сервера проще, чем из JS). Но, это задача на следующую неделю уже…

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое