2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HTA(HTML Application)をPreactとTypeScriptで開発する

Posted at

HTAはHTML+CSS+VBScript/JScriptでGUIアプリケーションを構築するレガシースクリプト技術です。少なくとも執筆時点ではWindows 11にも標準で搭載されています。これを、いまをときめく技術で書けたらきっと愉快です。実用性はありません。

HTAについて

だいたいInternet Explorerにいろいろ生えたElectron風のものと考えてよいでしょう。基本的な中身はIE対応のWebページと同様です。

<meta charset="utf-8">の有無でフォントのレンダリングが変わりました。titlehta:applicationより前に記述しないとウィンドウタイトルに反映されません。x-ua-compatibleによってIEのバージョンを指定できますが、IE=10以降ではHTA:APPLICATIONオブジェクトの機能が事実上利用できません。IE=9で実行できるようにトランスパイルするのがHTA開発の正道と言えます1

hta_allEX.hta
<HTML>
<HEAD>
  <meta charset="utf-8">
  <meta http-equiv="x-ua-compatible" content="IE=9">

  <!-- From https://learn.microsoft.com/en-us/previous-versions/ms536495(v=vs.85) -->
  <TITLE>HTA Demo</TITLE>
    <HTA:APPLICATION ID="oHTA"
     APPLICATIONNAME="myApp"
     BORDER="thin"
     BORDERSTYLE="normal"
     CAPTION="yes"
     ICON=""
     MAXIMIZEBUTTON="yes"
     MINIMIZEBUTTON="yes"
     SHOWINTASKBAR="no"
     SINGLEINSTANCE="no"
     SYSMENU="yes"
     VERSION="1.0"
     WINDOWSTATE="maximize"/>

  <SCRIPT>

  /* This function also retrieves the value of the commandLine property,
     which cannot be set as an attribute.  */
   
  window.onload = function()
  {
     sTempStr = "applicationName  = " + oHTA.applicationName + "\n" + 
                "border           = " + oHTA.border          + "\n" +
                "borderStyle      = " + oHTA.borderStyle     + "\n" + 
                "caption          = " + oHTA.caption         + "\n" +
                "commandLine      = " + oHTA.commandLine     + "\n" +
                "icon             = " + oHTA.icon            + "\n" +
                "maximizeButton   = " + oHTA.maximizeButton  + "\n" +
                "minimizeButton   = " + oHTA.minimizeButton  + "\n" + 
                "showInTaskBar    = " + oHTA.showInTaskbar   + "\n" +
                "singleInstance   = " + oHTA.singleInstance  + "\n" +  
                "sysMenu          = " + oHTA.sysMenu         + "\n" + 
                "version          = " + oHTA.version         + "\n" + 
                "windowState      = " + oHTA.windowState     + "\n" ;
          
     oPre.innerText = sTempStr;       
  }
  </SCRIPT>
</HEAD>
<BODY SCROLL="no">
  <PRE ID=oPre>  </PRE>
</BODY>
</HTML>

技術選定

今回はHTAを最新のTypeScript(TSX)で書くことを目指します。

Reactはv18でIE対応を終了しました2。v17以前を使ったり、v18も何らか工夫したりしたら使えるかもしれませんが、あまり考えたくありません。PreactであればIE11対応を明言しています3ので、IE9対応まで落とし込める可能性は高そうです。今回はこちらを使用します。

create-preactのようなスキャフォールディングツールもありますが、これらは当然モダンフロントエンド開発に使用されることを前提としているので、HTA対応には要修正のポイントが多くなります。手作業で依存ライブラリをインストールし、ファイルを配置するほうがマシです。

ES5までトランスパイルが必要です。*.hta単ファイルで実行できると取り回しがよいので、バンドル+埋め込みまで行います。webpack+Babelに慣れているのでこれらを使いますが、他のツールでも可能なはずです。

IE9にはfetchがありません。特別使いたいわけではありませんが、練習も兼ねて含めます。

開発

PS > mkdir my-hta-preact
PS > cd my-hta-preact
PS > npm init --yes
PS > npm install --save-dev `
        @babel/core `
        @babel/preset-env `
        @babel/preset-react `
        @babel/preset-typescript `
        babel-loader `
        html-inline-script-webpack-plugin `
        html-webpack-plugin `
        typescript `
        webpack `
        webpack-cli
PS > npm install --save `
        core-js `
        preact `
        regenerator-runtime `
        whatwg-fetch
PS > New-Item -Type File webpack.config.js,babel.config.js
PS > mkdir src
PS > New-Item -Type File src\app.hta,src\index.tsx
PS > echo "ie 9" > .browserslistrc
PS > npx tsc --init
webpack.config.js
new HtmlWebpackPlugin({
    filename: "app.hta",
    template: "src/app.hta",
    inject: "body",
    scriptLoading: "blocking"
}),

がキモです。deferなんて大層なものは使えません。過去のベストプラクティスに従ってbodyの最後に含めます。

webpack.config.js
const path = require("path");
const entry = path.resolve(__dirname, "src/index.tsx");

const webpack = require("webpack");

const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin');

module.exports = {
    mode: "development",
    entry: entry,
    output: {
        path: path.resolve(__dirname, "dist"),
        filename: "index.js"
    },
    target: "browserslist",
    module: {
        rules: [
            {
                test: /\.(jsx?|tsx?)$/,
                loader: "babel-loader",
            },
        ],
    },
    resolve: {
        extensions: [".js", ".jsx", ".ts", ".tsx"],

        // https://preactjs.com/guide/v10/getting-started#aliasing-in-webpack
        alias: {
            "react": "preact/compat",
            "react-dom/test-utils": "preact/test-utils",
            "react-dom": "preact/compat",
            "react/jsx-runtime": "preact/jsx-runtime"
        }
    },
    plugins: [
        new webpack.ProvidePlugin({
            fetch: ["whatwg-fetch", "fetch"],
            Headers: ["whatwg-fetch", "Headers"],
            Request: ["whatwg-fetch", "Request"],
            Response: ["whatwg-fetch", "Response"],

            "window.fetch": ["whatwg-fetch", "fetch"],
            "window.Headers": ["whatwg-fetch", "Headers"],
            "window.Request": ["whatwg-fetch", "Request"],
            "window.Response": ["whatwg-fetch", "Response"],
        }),

        new HtmlWebpackPlugin({
            filename: "app.hta",
            template: "src/app.hta",
            inject: "body",
            scriptLoading: "blocking"
        }),
        new HtmlInlineScriptPlugin({
            htmlMatchPattern: [/\.hta$/],
        }),
    ],
    devtool: false,
};
babel.config.js
babel.config.js
module.exports = {
    exclude: [
        // https://github.com/zloirock/core-js/issues/912
        "./node_modules/core-js"
    ],
    presets: [
        "@babel/preset-typescript",
        [
            // https://github.com/preactjs/preact-compat/issues/541#issuecomment-750382448
            "@babel/preset-react",
            {
                runtime: "automatic",
                importSource: "preact",
            }
        ],
        [
            "@babel/preset-env",
            {
                // debug: true,
                useBuiltIns: "usage",
                corejs: 3,
            }
        ]
    ],
};
tsconfig.json

前後数行以外はデフォルト値です。

tsconfig.json
{
  "compilerOptions": {
    // https://preactjs.com/guide/v10/typescript/#typescript-configuration
    "jsx": "react-jsx",
    "jsxImportSource": "preact",

    /* Visit https://aka.ms/tsconfig to read more about this file */

    /* Language and Environment */
    "target": "es2016",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */

    /* Modules */
    "module": "commonjs",                                /* Specify what module code is generated. */

    /* Interop Constraints */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */

    /* Type Checking */
    "strict": true,                                      /* Enable all strict type-checking options. */

    /* Completeness */
    "skipLibCheck": true,                                 /* Skip type checking all .d.ts files. */

    // https://preactjs.com/guide/v10/typescript/#typescript-preactcompat-configuration
    "baseUrl": "./",
    "paths": {
      "react": ["./node_modules/preact/compat/"],
      "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"],
      "react-dom": ["./node_modules/preact/compat/"]
    }
  }
}
src/app.hta

hta:applicationは適宜編集してください。

src/app.hta
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta http-equiv="x-ua-compatible" content="IE=9">

    <title>App</title>
    <hta:application id="oHTA" applicationname="App" version="1" />
</head>

<body>
    <div id="root"></div>
</body>

</html>

new ActiveXObject("WScript.Shell")に型をつけるには別途型定義が必要です。DefinitelyTypedに含まれていたように記憶しています。

src/index.tsx
import { render } from "preact";
import { useEffect, useState } from "preact/hooks";

const App = () => {
    const [winVer, setWinVer] = useState("");
    const [text, setText] = useState("");

    useEffect(() => {
        const process = new ActiveXObject("WScript.Shell").Exec("cmd /c ver");
        while (process.Status === 0)
            ;
        setWinVer(process.StdOut.ReadAll());

        const fetchTime = () => {
            fetch("http://worldtimeapi.org/api/timezone/Asia/Tokyo")
                .then(res => res.json())
                .then(json => setText(JSON.stringify(json)));
        };

        fetchTime();
        const id = setInterval(fetchTime, 2000);

        return () => clearInterval(id);
    }, []);

    return (
        <>
            <p>{oHTA.applicationName}</p>
            <p>{winVer}</p>
            <p>{text}</p>
        </>
    );
};

render(<App />, document.getElementById("root")!);

おまけで、HTA:APPLICATIONの型定義4も配置します。hta:applicationidに応じて生成されるグローバル変数であり、コマンドライン引数の取得等が可能です。

src/hta-application.d.ts
/** https://learn.microsoft.com/en-us/previous-versions/ms536495(v=vs.85) */
declare var oHTA: {
    /** https://web.archive.org/web/20120819100330/https://msdn.microsoft.com/en-us/library/ms533031(v=vs.85) */
    readonly applicationName: string;
    readonly border: "thick" | "dialog" | "none" | "thin";
    readonly borderStyle: "normal" | "complex" | "raised" | "static" | "sunken";
    readonly caption: "yes" | "no";
    readonly commandLine: string;
    readonly contextMenu: "yes" | "no";
    readonly icon: string;
    readonly innerBorder: "yes" | "no";
    readonly maximizeButton: "yes" | "no";
    readonly minimizeButton: "yes" | "no";
    readonly navigable: "yes" | "no";
    readonly scroll: "yes" | "no" | "auto";
    readonly scrollFlat: "yes" | "no";
    readonly selection: "yes" | "no";
    readonly showInTaskBar: "yes" | "no";
    readonly singleInstance: "yes" | "no";
    readonly sysMenu: "yes" | "no";
    readonly version: string;
    readonly windowState: "normal" | "minimize" | "maximize";
};
PS > npx webpack build
PS > .\dist\app.hta

サンプルに特に意味はないですが、以下を表示しています。

  • HTAアプリケーション名を表示(HTA:APPLICATIONオブジェクトのデモ)
  • Windowsのバージョンを表示(ActiveXのデモ)
  • fetchで2秒おきに時刻を取得してきて表示(fetchのデモ)

image.png

TODO

デバッグが辛そう。jeremyben/HTAConsoleで下部にコンソールを出せる。

CSSを何も調べていないけど、JavaScriptほど柔軟にIE対応はできなさそう。普通にrunjuu/html-inline-css-webpack-pluginで埋め込めそうではある。

BigIntについて、GoogleChromeLabs/jsbiを使えば用は足せそう。BabelプラグインによってJSBIからBigIntに変換することを想定されていますが、逆をやろうとしているプラグインもあるにはありそう5

WebAssemblyのpolyfillであるevanw/polywasmが楽しそう。

  1. IE=9で開いた直後に、自身をIE=edgeに書き換えたページにリダイレクトするハックが存在します。HTA:APPLICATIONオブジェクトのすべてのプロパティは読込専用のstring値なので、リダイレクト時にクエリ文字列に詰め、遷移先で復元すればよいでしょう。location.hrefの値がリダイレクト先の値になるなど細かな問題は残ります。

  2. https://ja.react.dev/blog/2022/03/08/react-18-upgrade-guide#dropping-support-for-internet-explorer

  3. https://preactjs.com/about/browser-support/

  4. https://learn.microsoft.com/en-us/previous-versions/ms536495(v=vs.85)

  5. Yaffle/babel-plugin-transform-bigintDYSDF/babel-plugin-transform-bigint-to-jsbi

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?