HTAはHTML+CSS+VBScript/JScriptでGUIアプリケーションを構築するレガシースクリプト技術です。少なくとも執筆時点ではWindows 11にも標準で搭載されています。これを、いまをときめく技術で書けたらきっと愉快です。実用性はありません。
HTAについて
だいたいInternet Explorerにいろいろ生えたElectron風のものと考えてよいでしょう。基本的な中身はIE対応のWebページと同様です。
<meta charset="utf-8">
の有無でフォントのレンダリングが変わりました。title
はhta:application
より前に記述しないとウィンドウタイトルに反映されません。x-ua-compatible
によってIEのバージョンを指定できますが、IE=10
以降ではHTA:APPLICATION
オブジェクトの機能が事実上利用できません。IE=9
で実行できるようにトランスパイルするのがHTA開発の正道と言えます1。
<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
の最後に含めます。
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
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
前後数行以外はデフォルト値です。
{
"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
は適宜編集してください。
<!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に含まれていたように記憶しています。
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:application
のid
に応じて生成されるグローバル変数であり、コマンドライン引数の取得等が可能です。
/** 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
のデモ)
TODO
デバッグが辛そう。jeremyben/HTAConsoleで下部にコンソールを出せる。
CSSを何も調べていないけど、JavaScriptほど柔軟にIE対応はできなさそう。普通にrunjuu/html-inline-css-webpack-pluginで埋め込めそうではある。
BigIntについて、GoogleChromeLabs/jsbiを使えば用は足せそう。BabelプラグインによってJSBIからBigIntに変換することを想定されていますが、逆をやろうとしているプラグインもあるにはありそう5。
WebAssemblyのpolyfillであるevanw/polywasmが楽しそう。
-
IE=9
で開いた直後に、自身をIE=edge
に書き換えたページにリダイレクトするハックが存在します。HTA:APPLICATION
オブジェクトのすべてのプロパティは読込専用のstring
値なので、リダイレクト時にクエリ文字列に詰め、遷移先で復元すればよいでしょう。location.href
の値がリダイレクト先の値になるなど細かな問題は残ります。 ↩ -
https://ja.react.dev/blog/2022/03/08/react-18-upgrade-guide#dropping-support-for-internet-explorer ↩
-
https://learn.microsoft.com/en-us/previous-versions/ms536495(v=vs.85) ↩
-
Yaffle/babel-plugin-transform-bigint、DYSDF/babel-plugin-transform-bigint-to-jsbi ↩