lucidiot's cybrecluster

Une console JavaScript pour Internet Explorer 6, partie 1

Lucidiot Informatique 2022-09-04
Je commence à construire un outil pour explorer des API ActiveX.


J'ai présenté dans un précédent article les Web Wizards, un type d'assistant introduit dans Windows XP et qui avait déjà commencé à disparaître avec Windows Vista. Ces assistants permettaient d'imprimer des photos en ligne, publier un dossier sur le Web ou ajouter un favori réseau depuis un service en ligne et pas seulement avec une URL. Il est possible de modifier le Registre de Windows pour ajouter ses propres fournisseurs pour ces assistants et utiliser des pages Web locales, ce qui permet de jouer un peu avec ces assistants et de découvrir ce qu'on peut faire avec.

Avec ces pages Web, on peut utiliser du code JavaScript ou du VBScript, les deux langages pris en charge par Internet Explorer sous XP, ou utiliser des composants ActiveX, ou bien d'autres bricolages autorisés par IE à cette époque. Le problème, c'est que ces instances d'Internet Explorer n'ont pas de console pour permettre rapidement d'exécuter du JS ou du VBS. Dans le développement Web, la console JavaScript est un outil très utile parmi les divers outils de développement qui sont aujourd'hui accessibles dans la plupart des navigateurs modernes avec la touche F12. Avec un peu d'effort, vous pouvez même ouvrir les outils de développement d'un navigateur sur téléphone à partir d'un ordinateur, pour développer sur mobile. Mais à cette époque, les outils de développement sont encore en étant de sous-développement.

L'instance d'Internet Explorer à laquelle nous avons accès dans les Web Wizards, en tous cas sur le système où j'ai fait mes tests, est en version 7. On retrouvera cependant les versions 6, 7 ou 8, selon la version de Windows, les mises à jour installées, les versions d'Internet Explorer installées, ainsi que le contexte où ces instances sont embarquées. Ce dernier point est important car on verra dans de futurs articles qu'Internet Explorer se cache dans bien d'autres endroits de Windows XP.

Dans la version 8, on voit arriver des outils de développement qui commencent à ressembler aux outils modernes, et une console JavaScript très rudimentaire fait son apparition. Cependant, dans ces instances embarquées d'Internet Explorer où on n'a aucune interface de navigation, ces outils sont inaccessibles. De plus, on n'a pas accès à du VBScript dans cette console intégrée. Si on veut donc une console de développement dans ces contextes bizarres de Windows XP, il va nous falloir créer notre propre console.

Dans cet article, on va commencer par poser les bases pour pouvoir construire une console JavaScript, qu'on terminera dans l'article suivant. On verra ensuite plus tard comment faire la même chose pour du VBScript, et on pourra ensuite commencer à intégrer cette console un peu partout dans Windows.

Structure d'une console

Ce que les développeurs Web appellent aujourd'hui la « console JavaScript » devrait plutôt être appelé la REPL JavaScript. REPL est un initialisme pour Read-Eval-Print Loop, autrement dit une boucle dans laquelle on lit une instruction, on l'exécute, on affiche le résultat, et on recommence. Les programmes de REPL sont assez courants, surtout pour les langages interprétés et non compilés, et permettent de tester rapidement un bout de code ou de faire faire quelque chose à l'ordinateur sans se casser la tête à créer un projet de programmation entier. Un terminal Linux pourrait être appelé un REPL pour Bash.

Dans notre console pour XP, nous voulons donc la possibilité de taper du code, de le lancer, de regarder son résultat, et de pouvoir recommencer à l'infini. Puisque le code peut devenir assez long, surtout avec VBScript qui est sensible aux sauts de ligne, on va couper l'écran en deux : une moitié contiendra une zone de texte, et l'autre contiendra un historique du code exécuté et des résultats obtenus.

La structure en HTML est relativement simple :

<!DOCTYPE html>
<html>
  <head>
    <title>JavaScript Console</title>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=8" />
    <link rel="stylesheet" type="text/css" href="console.css" />
    <link rel="icon" type="image/x-icon" href="jsconsole.ico" />
    <link rel="shortcut icon" type="image/x-icon" href="jsconsole.ico" />
  </head>
  <body>
    <textarea id="input" placeholder="Type some JavaScript to run and press Shift+Enter"></textarea>
    <div id="output"></div>
    <div class="buttons">
      <input type="button" id="runbutton" value="run" />
      <input type="button" id="clearbutton" value="clear" />
    </div>
    <script type="text/javascript" language="javascript" src="console.js"></script>
  </body>
</html>

La textarea représente la zone où on saisira le code. Le <div id="output"> est là où on placera l'historique des exécutions et des résultats. Deux boutons seront affichés en bas à droite de cet historique, pour exécuter le code ou pour effacer l'historique quand il devient trop long et encombrant.

On notera la présence de <meta http-equiv="X-UA-Compatible" content="IE=8" />, un tag absolument nécessaire pour forcer Internet Explorer 8 à activer ses fonctionnalités les plus récentes. Cela nous fera passer de JScript 5.7 à JScript 5.8, ce qui ajoute notamment le support du format JSON. On pourra ainsi s'en servir dans nos consoles.

Style

On va maintenant ajouter un peu de CSS pour présenter ces éléments HTML comme on le voulait au départ. Ce fichier sera le même pour les deux consoles, puisque leur look est le même.

On peut commencer par définir que notre zone de saisie et notre historique sont tous les deux des éléments occupants toute la hauteur de l'écran, et 50% de sa largeur.

#input, #output {
  height: 100%;
  width: 50%;
  max-width: 50%;
  overflow: auto;
  box-sizing: border-box;
}

On utilise overflow: auto; pour autoriser des barres de défilement à apparaître, ce qui sera particulièrement pratique lorsque l'historique devient trop long. On permet aussi du défilement horizontal, dans l'éventualité ou un mot trop long ou quelque chose d'autre déborde de l'affichage ; il vaut mieux permettre de quand même voir le contenu, même si l'affichage ne sera pas parfait.

On utilise aussi box-sizing: border-box; pour s'assurer que nos limites de taille sont respectées et que des bordures, qu'elles soient définies par le navigateur, par nous-mêmes ou par du script exécuté dans la console, ne causent pas le débordement de la console hors de l'écran et l'apparition de barres de défilement. Toutes ces considérations des barres de défilement sont importantes, puisque notre console s'exécutera souvent dans des portions d'écran assez petites, comme par exemple le contenu d'un assistant Web. Chaque pixel compte, et les barres de défilement en prennent beaucoup.

Dans le même genre, on va aussi interdire le redimensionnement de la zone de texte. Il est assez fréquent dans les navigateurs que les textarea soient rendues redimensionnables, avec une petite poignée en bas à droite pour en changer la taille. C'est une fonctionnalité assez pratique quand on veut taper énormément de texte dedans, mais avec nos contraintes de taille ici, on ne peut pas se permettre ce luxe.

textarea {
  resize: none;
}

On peut ensuite placer nos boutons. Avec les deux éléments occupant déjà toute la page, il n'y a plus vraiment d'endroit où les mettre ; je compte donc les faire flotter par dessus la zone d'historique, en bas à droite. Cela nous évite d'avoir une zone complètement vide juste en dessous de l'historique pour deux petits boutons ; on gagne un peu d'espace vertical pour lire un peu plus de texte.

.buttons {
  position: absolute;
  bottom: 0;
  right: 0;
}

Si on ouvre maintenant la console, que ce soit dans un navigateur ou dans un assistant Web, on observera que nos deux colonnes ont des marges extérieures qui perturbent complètement leur placement. Pour éviter ça, on doit supprimer les marges sur la page entière ainsi que sur les colonnes. On déplace aussi le height: 100% pour garantir que notre page prenne bien toute la place qu'elle peut occuper.

html, body, #input, #output {
  margin: 0;
  height: 100%;
}

On va également désactiver complètement le défilement sur la page entière, car nos deux colonnes d'entrée et de sortie gèrent déjà le défilement et rien ne devrait en sortir. Cette désactivation sera aussi assez utile dans certaines versions d'Internet Explorer où notre fichier CSS n'est pas forcément très bien reçu.

html, body {
  overflow: hidden;
}

Enfin, on va régler le dernier gros problème, qu'on ne voit pas encore vraiment parce que notre zone d'historique est vide : l'historique se trouve actuellement en-dessous de notre zone de texte et pas à droite. Pour essayer de garantir sa position dans tous les navigateurs malgré le fait qu'on doive gérer plusieurs versions du pire navigateur possible, on va forcer complètement sa position :

#output {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  right: 0;
}

On va aussi ajouter quelques tags pour le style du texte en sortie : on va utiliser une police à chasse fixe, la même que dans la zone de saisie, puisque tout le monde s'attent à une telle police pour du code. On va aussi activer le retour automatique à la ligne, faire que les sauts de ligne dans le code HTML soient interprétés comme des vrais sauts de ligne sans avoir besoin de balises <br>, et permettre à des mots très longs d'occuper plusieurs lignes au lieu de déborder de la page.

#output {
  white-space: pre-wrap;
  word-break: break-all;
  font-family: monospace;
  font-size: 10pt;
}

Outils de logging

Puisque la console n'existe pas, les fonctions bien connues des développeurs JavaScript pour écrire dans la console, comme console.log, n'existent pas. On va donc commencer par essayer de les recréer. On peut déjà commencer par recréer l'objet console lui-même, en tant que variable globale :

window.console = {};

Toutes les fonctions fournies par les consoles modernes ne sont pas forcément faciles à implémenter, et on va donc fournir seulement les fonctions les plus utiles et utilisées. On va commencer par toutes les fonctions qui affichent des messages quelconques et ne font rien d'autre : console.log, console.info, console.warn, console.error et console.debug.

Ces cinq fonctions ont toutes le même comportement, mais les messages sont affichés avec des couleurs différentes pour les distinguer. On peut donc construire toutes les fonctions en une seule fois. On pourrait utiliser un niveau d'abstraction où une fonction prend une couleur et un message en paramètre pour changer la couleur, mais puisque les fonctions de log sont capables d'afficher tous leurs arguments et pas seulement un, j'ai préféré créer une fonction qui génère des fonctions. On pourra ainsi définir nos cinq fonctions comme ceci :

window.onload = function () {
  console.log = makeLogger();
  console.debug = makeLogger('darkgray');
  console.info = makeLogger();
  console.warn = makeLogger('orange');
  console.error = makeLogger('red');
}

console.log et console.info afficheront des messages en noir, la couleur par défaut. console.debug affiche les messages en gris, console.warn en orange, et console.error en rouge. Il n'y a plus qu'à écrire la fonction makeLogger.

var output = document.getElementById('output');
function makeLogger (color) {
  return function () {
    var entry = document.createElement('div');
    entry.style.margin = '0';
    entry.style.padding = '1px';
    entry.style.borderBottom = '1px solid lightgray';
    if (color) entry.style.color = color;
    var outputText = '';
    for (var i = 0; i < arguments.length; i++) {
      outputText += arguments[i].toString();
      if (i + 1 !== arguments.length) outputText += ' ';
    }
    entry.appendChild(document.createTextNode(outputText));
    output.appendChild(entry);
  }
}

On commence par créer un nouvel élément HTML, sans le placer dans le document. Ce div n'a pas de marges extérieures, a une marge intérieur d'un pixel pour ne pas coller le texte complètement au bord de la zone d'historique, et sa bordure inférieure est une ligne grise pour séparer chaque entrée de l'historique. On rajoute aussi la couleur du texte si elle a été définie. Le tout utilise style, ce qui n'est certainement pas très propre mais fonctionne très bien.

Les fonctions de logging modernes vont concaténer chaque argument l'un après l'autre, en les séparant par des espaces. Pour accéder à la liste de tous les arguments de la fonction, sachant que nous ne disposons pas de l'opérateur de destructuring de la norme ES6 (function (...args)), on utilise le mot-clé arguments, qui est automatiquement défini comme un tableau des arguments de la fonction en cours. On itère sur ce tableau, en utilisant une boucle for classique puisque for..of et for..in n'existent pas non plus, et on appelle toString() partout.

Enfin, on ajoute le texte à notre nouvelle entrée de log, et on ajoute l'entrée de log à l'historique, ce qui la fait apparaître immédiatement.

Gestion des sauts de ligne

Un problème qu'on va rapidement rencontrer lors de notre affichage est que les sauts de ligne sont en double. Cela est causé par Windows, qui utilise deux caractères pour indiquer des sauts de ligne, le fameux CRLF : le caractère retour chariot puis le caractère saut de ligne. Dans ce contexte JavaScript, ces deux caractères seront interprétés comme deux sauts de ligne distincts, et on a donc deux sauts de ligne au lieu d'un. Pour résoudre le problème on va juste supprimer tous les retours chariot et revenir à une norme plus courante où seul \n est utilisé.

function removeCarriageReturns (value) {
  return value.replace(/\r/g, '');
}

Ensuite, dans le code de makeLogger, on pourra utiliser removeCarriageReturns(outputText).

Gestion des valeurs non représentables

La méthode toString est la façon la plus simple d'obtenir une chaîne de caractères, mais elle n'est pas la plus fiable. D'abord, undefined et null sont, par définition, inexistants et n'ont donc aucun prototype. Le prototype est un objet qui défini quelles fonctions sont disponibles sur un objet, et il peut notamment être utilisé pour de l'héritage ou d'autres concepts de programmation objet. Sans prototype, aucune méthode n'existe, et donc appeler toString cause une erreur.

On va à la place utiliser le constructeur new String(...), qui sera toujours disponible même pour des valeurs étranges. Dans l'éventualité où ça ne fonctionne pas, on va au minimum afficher le type de la variable pour permettre au développeur de se débrouiller pour l'afficher correctement. On va mettre tout ça dans une fonction séparée pour simplifier :

function tryString (value) {
  try {
    return removeCarriageReturns(new String(value).toString());
  } catch (err) {
    // Give up, just print the type.
    return '[' + typeof value + ']';
  }
}

On utilise .toString() sur notre String car un objet String pourrait être considéré comme un objet, et plus comme une chaîne de caractères, ce qui pourrait faire apparaître un [object String] ou causer des problèmes ailleurs. Notre fonction de logging utilise maintenant outputText += tryString(arguments[i]); sans se soucier de plus de détails.

Ensuite, les objets afficheront par défaut le célèbre [object Object] au lieu de leur contenu, ce qui n'est pas très utile. On pourrait essayer d'utiliser du JSON, puisque nous avons forcé l'activation de JScript 5.8 qui l'implémente, mais tous les objets ne prennent pas ça en charge. La console de Internet Explorer 8 préfère même afficher juste {...} sans donner plus d'informations. On pourrait autrement essayer d'énumérer tous les valeurs d'un objet, avec for (var name in obj), mais toutes les valeurs ne sont pas forcément énumérables et tous les objets, notamment ceux de ActiveX, n'implémentent pas la capacité d'énumérer les propriétés. Nous verrons donc plutôt cette gestion des objets complexes dans un futur article ; la fonction tryString sera utile pour séparer toute cette gestion particulière.

Voilà, on a maintenant les cinq fonctions les plus souvent utilisées pour afficher des informations de débogage en JavaScript, et on peut théoriquement gérer toutes les valeurs même à un niveau très minimal. On va essayer cependant d'en implémenter un peu plus.

Assertions

La fonction console.assert peut être utilisée pour vérifier une expression. Si l'expression est fausse, on affiche un message d'erreur. Voici un exemple :

var count = prompt("Entrez un nombre positif.", 42);
console.assert(count >= 0, "Le nombre", count, "n'est pas positif !");

Si l'utilisateur entre un nombre négatif, un message d'erreur s'affiche avec le message Le nombre -4 n'est pas positif !. On doit maintenant gérer un cas un peu plus étrange dans nos arguments : le premier argument doit être géré séparément, et tous les autres devront être passés à console.error si on doit afficher le message.

console.assert = function (assertion) {
  if (assertion) return;
  var argArray = Array.prototype.slice.call(arguments);
  argArray.shift();
  console.error.apply(console, argArray);
}

D'abord, si l'assertion est vraie, on s'arrête là et on n'affiche rien. Ensuite, on va aller récupérer tous les arguments de la fonction, retirer le premier qui est notre assertion, et les envoyer à console.error.

Pour supprimer le premier élément de notre liste d'arguments, on utiliserait dans un tableau la méthode .shift(). Cependant, on ne peut pas appeler arguments.shift() car arguments n'est pas un Array mais un objet assez étrange. Avec JavaScript ES6, on pourrait utiliser [...arguments] pour itérer sur tous les arguments et remplir un tableau directement, qu'on peut modifier sans affecter le arguments original, mais nous sommes en JavaScript ES3.

La méthode .slice() d'un tableau, quand on ne lui donne aucun argument, va juste créer une copie du tableau. Elle n'est normalement exécutable que sur des tableaux, car elle s'attend à ce que son this soit un tableau, mais il est possible en JavaScript de faire exécuter une fonction d'un objet particulier sur n'importe quoi d'autre ; pour cela, on va accéder à la méthode slice de l'objet Array sans utiliser une instance d'Array : on passe par le prototype. Il n'existe en fait pas de notion d'instances, car nous ne sommes pas exactement en programmation orientée objet classique.

Array.prototype.slice nous renvoie un objet de type Function, et la méthode Function.prototype.call peut être utilisée pour appeler une fonction. Cette méthode s'attend à d'abord un premier paramètre qui sera le this de la méthode, ensuite n'importe quel nombre d'arguments qui seront les arguments de la fonction elle-même. Array.prototype.slice.call(arguments) est donc équivalent à appeler arguments.slice(), même si arguments n'est pas un tableau. Il s'avère que la fonction slice itère sur this sans trop se préoccuper du type, donc de cette façon, on arrive à obtenir une copie de ce pseudo-tableau qui est cette fois un tableau.

Avec cette copie, on peut donc enfin appeler .shift() pour supprimer l'assertion, et on peut donc passer tous les autres arguments à console.error. Mais comment passer un tableau d'arguments à une fonction ? En utilisant Function.prototype.apply. Cette méthode s'attend cette fois-ci à un objet qui définira this, et un tableau d'arguments au lieu d'un nombre inconnu d'arguments. On envoie console comme argument this puisque c'est le this normal auquel on s'attend, puis notre tableau.

On a ici une belle démonstration de l'utilité du sucre syntaxique du JS moderne. Avec du JavaScript ES6, notre fonction console.assert aurait pu être écrite de façon bien plus lisible :

console.assert = function (assertion, ...message) {
  if (!assertion) console.error(...message);
}

Mais si on avait droit à ça, on aurait déjà eu droit à un vrai console.assert géré par le navigateur et on n'aurait pas du tout ce genre de problèmes de toute façon.

Pile des appels

Une autre fonction utile pour mieux comprendre l'exécution d'un morceau de code JavaScript est console.trace(). Elle affiche la pile des appels, c'est-à-dire quelle fonction a appelé le code dans lequel on est, et quelle fonction a appelé cette fonction, etc. Il est possible dans certains navigateurs d'utiliser un objet Error pour obtenir une pile des appels, mais ce n'est pas vraiment possible avec IE. On va plutôt devoir utiliser les attributs callee et caller de l'objet arguments : callee est la fonction appelée, et caller est la fonction appelante. On peut ensuite utiliser caller.caller pour obtenir la fonction appelante de la fonction appelante, et ainsi de suite pour remonter la pile.

console.trace = function () {
  var current = arguments.callee.caller;
  var stack = 'console.trace(): \n ';
  while (current) {
    try {
      var funcString = current.toString();
      funcString = funcString.substring(funcString.indexOf('function') + 8);
      stack += funcString.substring(0, funcString.indexOf('(')).trim() || 'anonymous';
    } catch (err) {
      stack += 'unknown function';
    }
    stack += '\n ';
    current = current.caller;
  }
  console.log(stack.trim())
}

On commence par callee.caller parce que la fonction appelée est console.trace, qu'on n'a pas besoin d'afficher ; la fonction appelante de console.trace est donc la première fonction qui nous intéresse. On boucle sur chaque fonction appelante jusqu'à ce qu'on aie remonté toute la pile ; on aura un undefined quand il n'y aura plus de fonctions à traverser.

Pour récupérer le nom de la fonction, on va appeler Function.prototype.toString, qui renvoie une représentation textuelle de la fonction souvent assez proche du vrai code JS, avec function <nom> () { <code source> }. On ne s'intéresse qu'au nom, donc on commence par un premier substring pour supprimer function, puis on en effectue un deuxième pour supprimer tout ce qui est après la première parenthèse ouvrante.

Puisque les fonctions en JavaScript peuvent être anonymes, on se prépare à ce cas en utilisant || 'anonymous'. Si on a une chaîne vide, on aura ce nom à la place. On gère également d'éventuelles erreurs, puisque du code JavaScript pourrait être appelé dans des contextes bizarres, par exemple par du VBScript, par une extension de navigateur, ou encore par l'hôte de l'interpréteur dans le cas des assistants Web, et on pourrait avoir des fonctions dont on n'a aucune représentation textuelle.

On notera que notre fonction console.trace est bien moins avancée que les traces qu'on peut avoir dans un navigateur moderne, puisqu'on n'a que les noms des fonctions, et pas leur fichier ou la ligne exacte dans le fichier. C'est un inconvénient assez inévitable de ce système. Pour améliorer ça, il faudrait probablement utiliser Active Debugging, une portion de la technologie Active Scripting qui est à la base de VBA, VBScript et JScript dans Internet Explorer ou dans les autres contextes de script de Windows et qui n'est pas forcément disponible dans les contextes assez contraignants dans lesquels nous voulons notre console, comme les assistants Web. Active Debugging est un ensemble d'interfaces permettant d'implémenter un débogueur de script ou permettant de rendre certains objets plus faciles à inspecter. J'essaierai peut-être plus tard de creuser un peu dedans, mais c'est encore un autre long détour qui vaudra un ou plusieurs autres articles.

Un dernier détail : la méthode trim() n'existe tout simplement pas sur les chaînes de caractères. Cette méthode n'a été rendue disponible dans Internet Explorer qu'à partir de la version 10, puisqu'elle fait partie de ES5 et pas ES3. On peut cependant la réimplémenter assez facilement à l'aide d'expressions régulières :

if (!String.prototype.trim) {
  var _r = /(^\s+|\s+$)/g;
  String.prototype.trim = function () {
    return this.replace(_r, '');
  }
}

On stocke l'expression régulière dans une variable pour qu'elle ne soit compilée qu'une seule fois, et pas à chaque exécution de la fonction, ce qui donne un petit gain de temps d'exécution. Cette façon de réimplémenter des fonctions plus récentes dans des versions plus anciennes de JavaScript s'appelle des polyfills, et on peut considérer que tout ce projet n'est rien d'autre qu'un immense polyfill.

Conclusion

Voilà, on se retrouve maintenant à avoir la charpente de la console en place, et des fonctions bien utiles pour remplacer la console intégrée d'Internet Explorer quand elle n'est pas là. Dans le prochain article, on exécutera concrètement du code, on améliorera l'interface utilisateur, on gérera quelques cas particuliers selon les versions d'Internet Explorer et le contexte où on utilise la console, et on intégrera tout ça pour pouvoir l'utiliser dans nos assistants Web.


Commentaires

Il n'y a pour l'instant aucun commentaire. Soyez le premier !