@takagaki/cortex-decisions-viewer 0.12.198 → 0.12.200
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/google-confirm-page.js +204 -124
- package/dist/render.js +118 -24
- package/package.json +1 -1
|
@@ -1,47 +1,56 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Drive・Google Chat の追加の確認ページ(cortex-tools #1357・設計 v3 §4
|
|
3
|
+
* Drive・Google Chat の追加の確認ページ(cortex-tools #1357・設計 v3 §4・「一発で Google へ」の改訂)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 追加欄(ビューア本体)は、このページを**小さな窓**で開く(本体のクリックの中・1 回だけ)。Google のトークンは**このページの中だけ**で扱い、
|
|
6
6
|
* intake の呼び出しもこのページが行って、ビューア本体には結果(成功か・画面に出す文言)だけを返す。
|
|
7
7
|
* ビューア全体の CSP は緩めない——このページだけが Google の画面(Picker)を載せるので、ポータルの配信側が
|
|
8
8
|
* このページのパスにだけ専用の CSP を当てる(classmethod-cortex-portal の `ViewerGoogleConfirm…` ポリシー)。
|
|
9
9
|
*
|
|
10
|
-
* ##
|
|
10
|
+
* ## 流れ(利用者が押すのは[Google で選ぶ]・アカウント・[許可]・フォルダだけ)
|
|
11
11
|
*
|
|
12
|
-
* 1.
|
|
13
|
-
* (指示には intake の宛先・名乗り券・種別・追加か確認か・Chat の URL か確認したい行の ID が入る)
|
|
14
|
-
* 2.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* 1. 本体が `window.open` でこのページを開く → このページが `ready` を返す → 本体が指示(`start`)を返す
|
|
13
|
+
* (指示には intake の宛先・名乗り券・種別・追加か確認か・Chat の URL か確認したい行の ID・`flow` が入る)
|
|
14
|
+
* 2. **クリック無しで**そのまま: intake に nonce をもらい、**この窓そのもの**を Google の承認画面へ移す(`location.replace`)。
|
|
15
|
+
* `state = flow + "." + base64url(このページの pathname)`。承認用の窓を別に開かない(`window.open` はもう無い)
|
|
16
|
+
* 3. 承認が終わると、ポータルの戻り先の頁(`/google-confirm-callback.html`)が、state の pathname の確認ページへ
|
|
17
|
+
* フラグメントごと転送する(同じオリジンの 1 画面だけ)。このページは戻った最初に URL からフラグメントを消す
|
|
18
|
+
* 4. Google の窓の切り替えで opener は切れていることがあるので、本体とは `BroadcastChannel("…-v2.<flow>")` で再接続する
|
|
19
|
+
* (`resume {flow}` → 本体が flow の一致を確かめて `start` を返す=state の突き合わせは本体が持つ)。storage は使わない
|
|
19
20
|
* 5. Drive は Picker でフォルダを選ぶ。選んだ ID(Chat は URL)とトークン 2 本と nonce を、op に **1 回で**送る
|
|
20
|
-
*
|
|
21
|
+
* (nonce は返った id_token の `nonce` クレームから読む。サーバが検証済みトークンと台帳で突き合わせる)
|
|
22
|
+
* 6. 結果を本体へ返し、成功なら閉じる(閉じられなければ[閉じる])
|
|
21
23
|
*
|
|
22
|
-
* 🔴 トークン・nonce は変数に持つだけ。
|
|
23
|
-
*
|
|
24
|
+
* 🔴 トークン・nonce は変数に持つだけ。storage・DOM・ログに書かない。本体へはトークンを渡さない。
|
|
25
|
+
* postMessage は送り元・送り先のオリジンを確かめる(同じオリジンだけ)。
|
|
24
26
|
*
|
|
25
27
|
* 文言(画面に出る言葉)の正本は intake の `GOOGLE_CONFIRM_MESSAGES`。ここにあるのは、サーバに届く前の失敗の言葉だけ。
|
|
26
28
|
*/
|
|
27
29
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
-
exports.GOOGLE_CONFIRM_SCOPES = exports.
|
|
30
|
+
exports.GOOGLE_CONFIRM_SCOPES = exports.GOOGLE_CONFIRM_WINDOW_FEATURES = exports.GOOGLE_CONFIRM_CHANNEL_PREFIX = exports.GOOGLE_CONFIRM_CALLBACK_PATH = exports.GOOGLE_CONFIRM_POST_TYPES = exports.GOOGLE_CONFIRM_PAGE_FILE = void 0;
|
|
29
31
|
exports.googleConfirmAssetName = googleConfirmAssetName;
|
|
30
32
|
exports.googleConfirmPageJs = googleConfirmPageJs;
|
|
31
33
|
exports.renderGoogleConfirmPage = renderGoogleConfirmPage;
|
|
32
34
|
const node_crypto_1 = require("node:crypto");
|
|
33
35
|
/** ページのファイル名(ビューアの出力の直下。本体からの相対参照で開く) */
|
|
34
36
|
exports.GOOGLE_CONFIRM_PAGE_FILE = "google-confirm.html";
|
|
35
|
-
/**
|
|
37
|
+
/** 本体とページの間のメッセージの型(本体の埋め込み JS にも同じ綴りを流し込む)。`ready`/`start` は opener、`resume`(チャンネルだけ)・`leaving`/`result`(チャンネルと生きていれば opener)・`start`(返事)は BroadcastChannel */
|
|
36
38
|
exports.GOOGLE_CONFIRM_POST_TYPES = {
|
|
37
39
|
ready: "cortex-google-confirm-ready",
|
|
38
40
|
start: "cortex-google-confirm-start",
|
|
39
41
|
result: "cortex-google-confirm-result",
|
|
42
|
+
leaving: "cortex-google-confirm-leaving",
|
|
43
|
+
resume: "cortex-google-confirm-resume",
|
|
44
|
+
alive: "cortex-google-confirm-alive",
|
|
40
45
|
};
|
|
41
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* ポータルの戻り先の頁と、窓が Google から戻ったあとに本体と再接続する BroadcastChannel の名前(`<名前><flow>`)。
|
|
48
|
+
* 旧版の戻り先の頁が `cortex-google-confirm`(トークン入りの hash)を流すので、別の名前にして本体の受け口に混ざらないようにする
|
|
49
|
+
*/
|
|
42
50
|
exports.GOOGLE_CONFIRM_CALLBACK_PATH = "/google-confirm-callback.html";
|
|
43
|
-
exports.
|
|
44
|
-
|
|
51
|
+
exports.GOOGLE_CONFIRM_CHANNEL_PREFIX = "cortex-google-confirm-v2.";
|
|
52
|
+
/** 本体が確認ページを開く窓の features(小さな窓。`popup` と大きさ) */
|
|
53
|
+
exports.GOOGLE_CONFIRM_WINDOW_FEATURES = "popup=yes,width=520,height=680,resizable=yes,scrollbars=yes";
|
|
45
54
|
/** 種別ごとの承認のスコープ(`openid` は共通)。intake の `GOOGLE_SCOPES` と同じ綴り(テストが突き合わせる) */
|
|
46
55
|
exports.GOOGLE_CONFIRM_SCOPES = {
|
|
47
56
|
drive: "https://www.googleapis.com/auth/drive.file",
|
|
@@ -55,15 +64,22 @@ function googleConfirmAssetName(js) {
|
|
|
55
64
|
function googleConfirmPageJs() {
|
|
56
65
|
return `(function () {
|
|
57
66
|
"use strict";
|
|
67
|
+
// 🔴 戻ってきたときのフラグメント(トークン入り)は、何よりも先に URL から消して変数にだけ持つ
|
|
68
|
+
var RETURNED_HASH = location.hash || "";
|
|
69
|
+
if (RETURNED_HASH) {
|
|
70
|
+
try { history.replaceState(null, "", location.pathname + location.search); } catch (e) { /* 消せなくても、ここから外へは何も送らない */ }
|
|
71
|
+
}
|
|
58
72
|
var MSG = ${JSON.stringify(exports.GOOGLE_CONFIRM_POST_TYPES)};
|
|
59
73
|
var CALLBACK_PATH = ${JSON.stringify(exports.GOOGLE_CONFIRM_CALLBACK_PATH)};
|
|
60
|
-
var
|
|
61
|
-
var CALLBACK_TYPE = ${JSON.stringify(exports.GOOGLE_CONFIRM_CALLBACK_TYPE)};
|
|
74
|
+
var CHANNEL_PREFIX = ${JSON.stringify(exports.GOOGLE_CONFIRM_CHANNEL_PREFIX)};
|
|
62
75
|
var SCOPES = ${JSON.stringify(exports.GOOGLE_CONFIRM_SCOPES)};
|
|
63
76
|
var ORIGIN = location.origin;
|
|
64
77
|
var UNFINISHED = "Google での確認が終わっていません。もう一度押してください";
|
|
65
|
-
var
|
|
78
|
+
var UNAVAILABLE = "今は確認できません。しばらくしてからもう一度";
|
|
79
|
+
var NO_PARENT = "元の画面が見つかりません。追加欄からもう一度やり直してください";
|
|
80
|
+
var RESUME_WAIT_MS = 8000;
|
|
66
81
|
var ADMIN_NOTE = "相手の管理者がこのアプリを許可する必要があります";
|
|
82
|
+
var FLOW_RE = /^[0-9a-f]{32}$/;
|
|
67
83
|
|
|
68
84
|
var $ = function (id) { return document.getElementById(id); };
|
|
69
85
|
var titleEl = $("gc-title");
|
|
@@ -71,8 +87,14 @@ function googleConfirmPageJs() {
|
|
|
71
87
|
var statusEl = $("gc-status");
|
|
72
88
|
var goBtn = $("gc-go");
|
|
73
89
|
var closeBtn = $("gc-close");
|
|
90
|
+
var busyEl = $("gc-busy");
|
|
91
|
+
var busyTextEl = $("gc-busy-text");
|
|
92
|
+
var busyNoteEl = $("gc-busy-note");
|
|
93
|
+
var pageEl = $("gc-page");
|
|
74
94
|
|
|
75
95
|
var ctx = null;
|
|
96
|
+
var flow = null;
|
|
97
|
+
var channel = null;
|
|
76
98
|
var busy = false;
|
|
77
99
|
var finished = false;
|
|
78
100
|
|
|
@@ -80,38 +102,69 @@ function googleConfirmPageJs() {
|
|
|
80
102
|
statusEl.textContent = text || "";
|
|
81
103
|
statusEl.className = "gc-status" + (cls ? " " + cls : "");
|
|
82
104
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
/** 窓の中全体を「読み込み中」で覆う(保存が終わるまで他の操作をしかけないため)。覆いの間は後ろの画面を操作できない */
|
|
106
|
+
function showBusy(text, note) {
|
|
107
|
+
busyTextEl.textContent = text;
|
|
108
|
+
busyNoteEl.textContent = note || "";
|
|
109
|
+
busyNoteEl.hidden = !note;
|
|
110
|
+
busyEl.hidden = false;
|
|
111
|
+
pageEl.setAttribute("inert", "");
|
|
112
|
+
pageEl.setAttribute("aria-hidden", "true");
|
|
89
113
|
}
|
|
90
|
-
|
|
91
|
-
|
|
114
|
+
function hideBusy() {
|
|
115
|
+
busyEl.hidden = true;
|
|
116
|
+
pageEl.removeAttribute("inert");
|
|
117
|
+
pageEl.removeAttribute("aria-hidden");
|
|
118
|
+
}
|
|
119
|
+
function b64urlEncode(text) {
|
|
120
|
+
return btoa(text).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
|
|
121
|
+
}
|
|
122
|
+
/** 本体へ送る。同じ BroadcastChannel(flow 入りの名前)と、生きていれば opener の両方(オリジンは同じものだけ) */
|
|
123
|
+
function send(message, channelOnly) {
|
|
124
|
+
var body = Object.assign({ flow: flow }, message);
|
|
125
|
+
try { if (channel) channel.postMessage(body); } catch (e) { /* 閉じている */ }
|
|
126
|
+
if (channelOnly) return;
|
|
92
127
|
try {
|
|
93
|
-
if (window.opener && !window.opener.closed)
|
|
94
|
-
window.opener.postMessage(Object.assign({ type: MSG.result }, result), ORIGIN);
|
|
95
|
-
}
|
|
128
|
+
if (window.opener && !window.opener.closed) window.opener.postMessage(body, ORIGIN);
|
|
96
129
|
} catch (e) { /* 本体が閉じていても、このページの表示は残す */ }
|
|
97
130
|
}
|
|
98
|
-
|
|
131
|
+
function openChannel() {
|
|
132
|
+
if (channel || !flow) return;
|
|
133
|
+
try {
|
|
134
|
+
channel = new BroadcastChannel(CHANNEL_PREFIX + flow);
|
|
135
|
+
channel.onmessage = function (ev) { acceptStart(ev.data); };
|
|
136
|
+
} catch (e) { channel = null; }
|
|
137
|
+
}
|
|
138
|
+
/** 成功: 本体へ結果を返し、閉じる(閉じられなければ[閉じる]と案内が残る) */
|
|
99
139
|
function succeed(message, extra) {
|
|
100
140
|
finished = true;
|
|
101
141
|
busy = false;
|
|
142
|
+
hideBusy();
|
|
102
143
|
goBtn.hidden = true;
|
|
103
144
|
closeBtn.hidden = false;
|
|
104
|
-
setStatus("✓ " + message, "is-ok");
|
|
105
|
-
|
|
145
|
+
setStatus("✓ " + message + "。このウィンドウは閉じて構いません", "is-ok");
|
|
146
|
+
send(Object.assign({ type: MSG.result, ok: true, message: message }, extra || {}));
|
|
106
147
|
setTimeout(function () { try { window.close(); } catch (e) { /* 閉じられなくても表示は残る */ } }, 1200);
|
|
107
148
|
}
|
|
108
149
|
/** 失敗: 理由を出し、押し直せるように戻す(別のアカウントでやり直す人のため)。本体にも理由を渡す */
|
|
109
150
|
function retryable(message, extra) {
|
|
110
151
|
busy = false;
|
|
152
|
+
hideBusy();
|
|
153
|
+
goBtn.hidden = false;
|
|
111
154
|
goBtn.disabled = false;
|
|
112
155
|
goBtn.textContent = "もう一度 Google で確認する";
|
|
156
|
+
closeBtn.hidden = false;
|
|
157
|
+
setStatus("✗ " + message, "is-error");
|
|
158
|
+
send(Object.assign({ type: MSG.result, ok: false, message: message }, extra || {}));
|
|
159
|
+
}
|
|
160
|
+
/** 続けられない(本体が見つからない・結果が読めない): 理由と[閉じる]だけ */
|
|
161
|
+
function dead(message) {
|
|
162
|
+
busy = false;
|
|
163
|
+
hideBusy();
|
|
164
|
+
finished = true;
|
|
165
|
+
goBtn.hidden = true;
|
|
166
|
+
closeBtn.hidden = false;
|
|
113
167
|
setStatus("✗ " + message, "is-error");
|
|
114
|
-
reportToOpener(Object.assign({ ok: false, message: message }, extra || {}));
|
|
115
168
|
}
|
|
116
169
|
|
|
117
170
|
/** intake を 1 回呼ぶ。戻り値は { status, body }(body は JSON か、文字列のとき { message } に直す) */
|
|
@@ -133,50 +186,11 @@ function googleConfirmPageJs() {
|
|
|
133
186
|
var intake = ctx.intake;
|
|
134
187
|
var url = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&") + "op=google-confirm-info&t=" + encodeURIComponent(intake.token);
|
|
135
188
|
return fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ctx.ticket ? { actor: ctx.ticket } : {}) })
|
|
136
|
-
.then(function (res) { return res.json().catch(function () { return {}; }); })
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return new Promise(function (resolve) {
|
|
142
|
-
var done = false;
|
|
143
|
-
var bc = null;
|
|
144
|
-
var timer = null;
|
|
145
|
-
function settle(value) {
|
|
146
|
-
if (done) return;
|
|
147
|
-
done = true;
|
|
148
|
-
clearInterval(timer);
|
|
149
|
-
window.removeEventListener("message", onMessage);
|
|
150
|
-
try { if (bc) bc.close(); } catch (e) { /* 閉じられなくても害は無い */ }
|
|
151
|
-
resolve(value);
|
|
152
|
-
}
|
|
153
|
-
function accept(data) {
|
|
154
|
-
if (!data || data.type !== CALLBACK_TYPE || typeof data.hash !== "string") return;
|
|
155
|
-
var params = new URLSearchParams(data.hash.replace(/^#/, ""));
|
|
156
|
-
// 別の流れの結果(古い承認・別のタブ)は受けない
|
|
157
|
-
if (params.get("state") !== state) return;
|
|
158
|
-
settle({ params: params });
|
|
159
|
-
}
|
|
160
|
-
function onMessage(ev) {
|
|
161
|
-
// 🔴 送り元のオリジンと、開いた承認用のウィンドウであることを確かめる
|
|
162
|
-
if (ev.origin !== ORIGIN || ev.source !== authWin) return;
|
|
163
|
-
accept(ev.data);
|
|
164
|
-
}
|
|
165
|
-
window.addEventListener("message", onMessage);
|
|
166
|
-
try {
|
|
167
|
-
bc = new BroadcastChannel(CHANNEL);
|
|
168
|
-
bc.onmessage = function (ev) { accept(ev.data); };
|
|
169
|
-
} catch (e) { bc = null; }
|
|
170
|
-
// 承認のウィンドウが(結果を返さずに)閉じられた
|
|
171
|
-
// ブラウザによっては、承認の途中で Google 側の設定によりウィンドウの所属が切り替わり、こちらから見て closed が真になる。
|
|
172
|
-
// すぐ失敗にせず、結果(BroadcastChannel)を少し待つ
|
|
173
|
-
var closedAt = 0;
|
|
174
|
-
timer = setInterval(function () {
|
|
175
|
-
if (!authWin.closed) { closedAt = 0; return; }
|
|
176
|
-
if (!closedAt) closedAt = Date.now();
|
|
177
|
-
if (Date.now() - closedAt >= CLOSED_GRACE_MS) settle({ closed: true });
|
|
178
|
-
}, 500);
|
|
179
|
-
});
|
|
189
|
+
.then(function (res) { return res.json().catch(function () { return {}; }); })
|
|
190
|
+
.then(function (i) {
|
|
191
|
+
if (!i || i.enabled !== true || i.ready !== true) throw { message: UNAVAILABLE };
|
|
192
|
+
return i;
|
|
193
|
+
});
|
|
180
194
|
}
|
|
181
195
|
|
|
182
196
|
function authUrl(info, nonce, state) {
|
|
@@ -192,6 +206,15 @@ function googleConfirmPageJs() {
|
|
|
192
206
|
];
|
|
193
207
|
return "https://accounts.google.com/o/oauth2/v2/auth?" + q.map(function (kv) { return kv[0] + "=" + encodeURIComponent(kv[1]); }).join("&");
|
|
194
208
|
}
|
|
209
|
+
/** 返った id_token の nonce クレーム(検証はサーバ。ここは送り返すために読むだけ) */
|
|
210
|
+
function nonceOfIdToken(idToken) {
|
|
211
|
+
try {
|
|
212
|
+
var payload = idToken.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
213
|
+
var nonce = JSON.parse(atob(payload)).nonce;
|
|
214
|
+
if (typeof nonce === "string" && nonce) return nonce;
|
|
215
|
+
} catch (e) { /* 読めない */ }
|
|
216
|
+
throw { message: UNFINISHED };
|
|
217
|
+
}
|
|
195
218
|
|
|
196
219
|
function loadScript(src) {
|
|
197
220
|
return new Promise(function (resolve, reject) {
|
|
@@ -211,7 +234,7 @@ function googleConfirmPageJs() {
|
|
|
211
234
|
var g = window.google.picker;
|
|
212
235
|
var mine = new g.DocsView(g.ViewId.FOLDERS).setIncludeFolders(true).setSelectFolderEnabled(true).setMimeTypes("application/vnd.google-apps.folder");
|
|
213
236
|
var shared = new g.DocsView(g.ViewId.FOLDERS).setIncludeFolders(true).setSelectFolderEnabled(true).setEnableDrives(true).setMimeTypes("application/vnd.google-apps.folder");
|
|
214
|
-
new g.PickerBuilder()
|
|
237
|
+
var picker = new g.PickerBuilder()
|
|
215
238
|
.setAppId(info.projectNumber)
|
|
216
239
|
.setDeveloperKey(info.apiKey)
|
|
217
240
|
.setOAuthToken(accessToken)
|
|
@@ -222,8 +245,10 @@ function googleConfirmPageJs() {
|
|
|
222
245
|
if (data.action === g.Action.PICKED && data.docs && data.docs[0]) resolve(data.docs[0]);
|
|
223
246
|
else if (data.action === g.Action.CANCEL) resolve(null);
|
|
224
247
|
})
|
|
225
|
-
.build()
|
|
226
|
-
|
|
248
|
+
.build();
|
|
249
|
+
// Picker を操作できるように、出す前に覆いを外す(選ぶ・やめるのあとは、呼び出し側が覆いを戻すか結果を出す)
|
|
250
|
+
hideBusy();
|
|
251
|
+
picker.setVisible(true);
|
|
227
252
|
});
|
|
228
253
|
});
|
|
229
254
|
}
|
|
@@ -235,22 +260,22 @@ function googleConfirmPageJs() {
|
|
|
235
260
|
}
|
|
236
261
|
return UNFINISHED;
|
|
237
262
|
}
|
|
263
|
+
function failureMessage(e) {
|
|
264
|
+
// 通信そのものの失敗(Error)の英語の文面は画面に出さない
|
|
265
|
+
return Object.prototype.toString.call(e) === "[object Error]" ? UNAVAILABLE : e && e.message ? e.message : UNFINISHED;
|
|
266
|
+
}
|
|
238
267
|
|
|
239
|
-
/**
|
|
240
|
-
function
|
|
268
|
+
/** 🔴 クリック無しで進む: nonce をもらい、この窓そのものを Google の承認画面へ移す */
|
|
269
|
+
function go() {
|
|
241
270
|
if (busy || finished || !ctx) return;
|
|
242
271
|
busy = true;
|
|
243
|
-
goBtn.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (!authWin) { retryable("ポップアップが開けませんでした。ブラウザのポップアップを許可して、もう一度押してください"); return; }
|
|
272
|
+
goBtn.hidden = true;
|
|
273
|
+
closeBtn.hidden = true;
|
|
274
|
+
setStatus("Google の画面へ移動しています…", "");
|
|
275
|
+
showBusy("Google の画面へ移動しています…");
|
|
248
276
|
var info = null;
|
|
249
|
-
var nonce = null;
|
|
250
|
-
var tokens = null;
|
|
251
277
|
loadInfo()
|
|
252
278
|
.then(function (i) {
|
|
253
|
-
if (!i || i.enabled !== true || i.ready !== true) throw { message: "今は確認できません。しばらくしてからもう一度" };
|
|
254
279
|
info = i;
|
|
255
280
|
var body = { op: "google-nonce", kind: ctx.kind };
|
|
256
281
|
if (ctx.kind === "google-chat") body.url = ctx.url || ctx.id;
|
|
@@ -258,30 +283,48 @@ function googleConfirmPageJs() {
|
|
|
258
283
|
return callIntake(body);
|
|
259
284
|
})
|
|
260
285
|
.then(function (r) {
|
|
261
|
-
if (r.status !== 200 || !r.body.nonce) throw { message: r.body.message ||
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
286
|
+
if (r.status !== 200 || !r.body.nonce) throw { message: r.body.message || UNAVAILABLE };
|
|
287
|
+
var state;
|
|
288
|
+
try { state = flow + "." + b64urlEncode(location.pathname); } catch (e) { throw { message: UNAVAILABLE }; }
|
|
289
|
+
// 本体へ「これから Google へ移る」と知らせる(窓の見張りを、Google にいる間は失敗にしないため)
|
|
290
|
+
send({ type: MSG.leaving });
|
|
291
|
+
location.replace(authUrl(info, r.body.nonce, state));
|
|
267
292
|
})
|
|
268
|
-
.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
293
|
+
.catch(function (e) { retryable(failureMessage(e)); });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Google から戻ったあと: フラグメントのトークンで、Picker(Drive)→ op を 1 回 */
|
|
297
|
+
function afterReturn(params) {
|
|
298
|
+
if (busy || finished || !ctx) return;
|
|
299
|
+
busy = true;
|
|
300
|
+
goBtn.hidden = true;
|
|
301
|
+
closeBtn.hidden = true;
|
|
302
|
+
var isDrive = ctx.kind === "drive";
|
|
303
|
+
setStatus("確認しています…", "");
|
|
304
|
+
showBusy(isDrive ? "Google のフォルダを開いています…" : "確認しています…");
|
|
305
|
+
var tokens = null;
|
|
306
|
+
var info = null;
|
|
307
|
+
Promise.resolve()
|
|
308
|
+
.then(function () {
|
|
309
|
+
if (params.get("error")) throw { message: authErrorMessage(params.get("error")) };
|
|
310
|
+
var access = params.get("access_token");
|
|
311
|
+
var idToken = params.get("id_token");
|
|
275
312
|
if (!access || !idToken) throw { message: UNFINISHED };
|
|
276
|
-
tokens = { googleToken: access, googleIdToken: idToken, googleNonce:
|
|
313
|
+
tokens = { googleToken: access, googleIdToken: idToken, googleNonce: nonceOfIdToken(idToken) };
|
|
314
|
+
return loadInfo();
|
|
315
|
+
})
|
|
316
|
+
.then(function (i) {
|
|
317
|
+
info = i;
|
|
277
318
|
// Drive の追加と確認は、承認のあとに本人が Picker で選ぶ(drive.file はここで選んだものだけが読める)
|
|
278
|
-
if (ctx.kind === "drive") return pickFolder(info,
|
|
319
|
+
if (ctx.kind === "drive") return pickFolder(info, tokens.googleToken);
|
|
279
320
|
return null;
|
|
280
321
|
})
|
|
281
322
|
.then(function (picked) {
|
|
282
323
|
var body;
|
|
283
324
|
if (ctx.kind === "drive") {
|
|
284
325
|
if (!picked) throw { message: "フォルダを選ばずに閉じられました。もう一度押してください" };
|
|
326
|
+
if (ctx.mode === "confirm") showBusy("確認しています…");
|
|
327
|
+
else showBusy("保存しています…", "終わるまでこの窓を閉じずにお待ちください");
|
|
285
328
|
body = ctx.mode === "confirm"
|
|
286
329
|
? { op: "confirm-drive-folder", id: ctx.id, folderId: picked.id }
|
|
287
330
|
: { op: "add-drive-folder", folderId: picked.id };
|
|
@@ -302,20 +345,17 @@ function googleConfirmPageJs() {
|
|
|
302
345
|
})
|
|
303
346
|
.catch(function (e) {
|
|
304
347
|
tokens = null;
|
|
305
|
-
|
|
306
|
-
// 通信そのものの失敗(Error)の英語の文面は画面に出さない
|
|
307
|
-
var message = Object.prototype.toString.call(e) === "[object Error]" ? "今は確認できません。しばらくしてからもう一度" : e && e.message ? e.message : UNFINISHED;
|
|
308
|
-
retryable(message);
|
|
348
|
+
retryable(failureMessage(e));
|
|
309
349
|
});
|
|
310
350
|
}
|
|
311
351
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (ev.origin !== ORIGIN || ev.source !== window.opener) return;
|
|
315
|
-
var d = ev.data;
|
|
352
|
+
/** 本体からの指示(opener か BroadcastChannel)。flow が合うものだけを 1 回だけ受ける */
|
|
353
|
+
function acceptStart(d) {
|
|
316
354
|
if (!d || d.type !== MSG.start || ctx) return;
|
|
355
|
+
if (typeof d.flow !== "string" || !FLOW_RE.test(d.flow) || (flow && d.flow !== flow)) return;
|
|
317
356
|
if (!d.intake || typeof d.intake.url !== "string" || typeof d.intake.token !== "string") return;
|
|
318
357
|
if (d.kind !== "drive" && d.kind !== "google-chat") return;
|
|
358
|
+
flow = d.flow;
|
|
319
359
|
ctx = { intake: d.intake, ticket: typeof d.ticket === "string" ? d.ticket : null, kind: d.kind, mode: d.mode === "confirm" ? "confirm" : "add", url: typeof d.url === "string" ? d.url : "", id: typeof d.id === "string" ? d.id : "" };
|
|
320
360
|
var isDrive = ctx.kind === "drive";
|
|
321
361
|
var what = isDrive ? "Drive のフォルダ" : "Google Chat のスペース";
|
|
@@ -323,16 +363,49 @@ function googleConfirmPageJs() {
|
|
|
323
363
|
leadEl.textContent = isDrive
|
|
324
364
|
? "あなたの Google アカウントで、追加するフォルダを選びます。選んだフォルダだけを Cortex が確認に使います。「このアプリは Google で確認されていません」と出たら、「詳細」から進めます。"
|
|
325
365
|
: "あなたの Google アカウントで、このスペースを開けるかを確認します。メッセージは読みません。";
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
366
|
+
openChannel();
|
|
367
|
+
// 作業中(Picker の選択など)は本体へ生存の合図を送る。本体は、窓が閉じたように見えても、合図が続いている間は失敗にしない
|
|
368
|
+
setInterval(function () { if (!finished) send({ type: MSG.alive }, true); }, 3000);
|
|
369
|
+
if (returned) afterReturn(returned.params);
|
|
370
|
+
else go();
|
|
371
|
+
}
|
|
372
|
+
function onOpenerMessage(ev) {
|
|
373
|
+
// 🔴 同じオリジンの、このページを開いた本体からの指示だけを受ける
|
|
374
|
+
if (ev.origin !== ORIGIN || ev.source !== window.opener) return;
|
|
375
|
+
acceptStart(ev.data);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 戻ってきたとき: state から flow を取り出す(取れなければ続けられない) */
|
|
379
|
+
var returned = null;
|
|
380
|
+
if (RETURNED_HASH) {
|
|
381
|
+
var params = new URLSearchParams(RETURNED_HASH.replace(/^#/, ""));
|
|
382
|
+
var returnedState = params.get("state") || "";
|
|
383
|
+
var returnedFlow = returnedState.split(".")[0];
|
|
384
|
+
if (FLOW_RE.test(returnedFlow)) returned = { params: params, flow: returnedFlow };
|
|
329
385
|
}
|
|
330
386
|
|
|
331
|
-
goBtn.addEventListener("click",
|
|
387
|
+
goBtn.addEventListener("click", function () { go(); });
|
|
332
388
|
closeBtn.addEventListener("click", function () { try { window.close(); } catch (e) { /* 閉じられなければそのまま */ } });
|
|
333
|
-
window.addEventListener("message",
|
|
334
|
-
|
|
335
|
-
if (
|
|
389
|
+
window.addEventListener("message", onOpenerMessage);
|
|
390
|
+
|
|
391
|
+
if (returned) {
|
|
392
|
+
// 窓が Google から戻ってきた。opener は切れていることがあるので、flow 入りのチャンネルで本体を呼び、指示をもらう
|
|
393
|
+
flow = returned.flow;
|
|
394
|
+
openChannel();
|
|
395
|
+
setStatus("確認の続きを準備しています…", "");
|
|
396
|
+
showBusy("確認しています…");
|
|
397
|
+
var asked = 0;
|
|
398
|
+
var timer = setInterval(function () {
|
|
399
|
+
if (ctx) { clearInterval(timer); return; }
|
|
400
|
+
asked += 1;
|
|
401
|
+
if (asked * 1000 > RESUME_WAIT_MS) { clearInterval(timer); dead(NO_PARENT); return; }
|
|
402
|
+
send({ type: MSG.resume }, true);
|
|
403
|
+
}, 1000);
|
|
404
|
+
send({ type: MSG.resume }, true);
|
|
405
|
+
} else if (RETURNED_HASH) {
|
|
406
|
+
dead("確認の結果を読み取れませんでした。追加欄からもう一度やり直してください");
|
|
407
|
+
} else if (window.opener) {
|
|
408
|
+
// このページは本体から開かれたときだけ使える
|
|
336
409
|
try { window.opener.postMessage({ type: MSG.ready }, ORIGIN); } catch (e) { /* 本体が閉じた */ }
|
|
337
410
|
} else {
|
|
338
411
|
setStatus("このページは、追加欄の[Google で選ぶ]などから開きます。", "");
|
|
@@ -354,7 +427,7 @@ function renderGoogleConfirmPage(assets) {
|
|
|
354
427
|
<link rel="stylesheet" href="${esc(assets.css)}" />
|
|
355
428
|
</head>
|
|
356
429
|
<body class="gc-body">
|
|
357
|
-
<main class="gc-page">
|
|
430
|
+
<main id="gc-page" class="gc-page">
|
|
358
431
|
<h1 id="gc-title" class="gc-title">Google で確認</h1>
|
|
359
432
|
<p id="gc-lead" class="gc-lead"></p>
|
|
360
433
|
<p id="gc-status" class="gc-status" role="status" aria-live="polite"></p>
|
|
@@ -363,6 +436,13 @@ function renderGoogleConfirmPage(assets) {
|
|
|
363
436
|
<button id="gc-close" class="ds-toggle-btn" type="button" hidden>閉じる</button>
|
|
364
437
|
</div>
|
|
365
438
|
</main>
|
|
439
|
+
<div id="gc-busy" class="gc-busy" role="status" aria-live="polite" hidden>
|
|
440
|
+
<div class="gc-busy-box">
|
|
441
|
+
<span class="gc-spinner" aria-hidden="true"></span>
|
|
442
|
+
<p id="gc-busy-text" class="gc-busy-text"></p>
|
|
443
|
+
<p id="gc-busy-note" class="gc-busy-note" hidden></p>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
366
446
|
<script src="${esc(assets.js)}"></script>
|
|
367
447
|
</body>
|
|
368
448
|
</html>`;
|
package/dist/render.js
CHANGED
|
@@ -5603,6 +5603,15 @@ a.ds-tile:hover { text-decoration: none; }
|
|
|
5603
5603
|
.gc-status.is-ok { color: #1a7f37; }
|
|
5604
5604
|
.gc-status.is-error { color: #b42318; }
|
|
5605
5605
|
.gc-actions { display: flex; gap: 8px; }
|
|
5606
|
+
/* 保存が終わるまで窓の中全体を覆う「読み込み中」。CSS だけで回す(外から読み込むものは CSP で落ちる)。窓は幅 520・高さ 680 */
|
|
5607
|
+
.gc-busy { position: fixed; inset: 0; z-index: 10; display: flex; align-items: center; justify-content: center; padding: 16px; background: var(--bg, #fff); }
|
|
5608
|
+
.gc-busy[hidden] { display: none; }
|
|
5609
|
+
.gc-busy-box { max-width: 360px; text-align: center; }
|
|
5610
|
+
.gc-spinner { display: block; width: 44px; height: 44px; margin: 0 auto 16px; border: 4px solid var(--border, #e3e6ea); border-top-color: var(--accent, #2f6fed); border-radius: 50%; animation: gc-spin 0.9s linear infinite; }
|
|
5611
|
+
.gc-busy-text { margin: 0; font-size: 18px; font-weight: 600; color: var(--text, #1a1a1a); }
|
|
5612
|
+
.gc-busy-note { margin: 8px 0 0; font-size: 13px; color: var(--muted); }
|
|
5613
|
+
@keyframes gc-spin { to { transform: rotate(360deg); } }
|
|
5614
|
+
@media (prefers-reduced-motion: reduce) { .gc-spinner { animation-duration: 3s; } }
|
|
5606
5615
|
.gc-unverified { font-size: 12px; color: #9a6700; border: 1px solid #d4a72c; border-radius: 4px; padding: 0 6px; }
|
|
5607
5616
|
.gc-verify-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
|
5608
5617
|
/* Backlog の同期経路のスイッチ(#516)。role=switch の button に、トラックとつまみを描く。
|
|
@@ -6248,6 +6257,14 @@ const CLIENT_JS = `
|
|
|
6248
6257
|
// 注: ここは埋め込みJSのテンプレートリテラル内。コメントでもバッククォートを使わない
|
|
6249
6258
|
var GC_PAGE = ${JSON.stringify(google_confirm_page_1.GOOGLE_CONFIRM_PAGE_FILE)};
|
|
6250
6259
|
var GC_MSG = ${JSON.stringify(google_confirm_page_1.GOOGLE_CONFIRM_POST_TYPES)};
|
|
6260
|
+
var GC_CHANNEL = ${JSON.stringify(google_confirm_page_1.GOOGLE_CONFIRM_CHANNEL_PREFIX)};
|
|
6261
|
+
var GC_FEATURES = ${JSON.stringify(google_confirm_page_1.GOOGLE_CONFIRM_WINDOW_FEATURES)};
|
|
6262
|
+
var GC_CLOSED_GRACE_MS = 6000;
|
|
6263
|
+
var GC_LEFT_GRACE_MS = 15000;
|
|
6264
|
+
var GC_TIMEOUT_MS = 600000;
|
|
6265
|
+
var GC_START_MAX = 5;
|
|
6266
|
+
// 進行中の確認は 1 つだけ(全ボタン共通)
|
|
6267
|
+
var gcPending = null;
|
|
6251
6268
|
var GC_UNFINISHED = "Google での確認が終わっていません。もう一度押してください";
|
|
6252
6269
|
var GC_UNAVAILABLE = "今は確認できません。しばらくしてからもう一度";
|
|
6253
6270
|
// サーバの op=state が返す googleConfirm(栓が切なら無い=null=今の追加欄のまま)。取れていない間も null
|
|
@@ -6273,45 +6290,122 @@ const CLIENT_JS = `
|
|
|
6273
6290
|
return !!c && Array.isArray(c[kind]) && c[kind].indexOf(id) >= 0;
|
|
6274
6291
|
}
|
|
6275
6292
|
/**
|
|
6276
|
-
*
|
|
6277
|
-
*
|
|
6293
|
+
* 確認ページを小さな窓で開き、結果を待つ。**ここで受け取るのは結果だけ**(トークンは来ない)。
|
|
6294
|
+
* 窓は Google へ移って戻ってくる間に opener が切れることがあるので、戻りは BroadcastChannel(flow 入りの名前)で受ける。
|
|
6295
|
+
* 🔴 flow は本体と確認ページしか知らない乱数。flow の合わないメッセージ・型の違うメッセージは捨てる。読むのは type・flow・ok・message・changed だけ。
|
|
6296
|
+
* 窓が閉じても、Google へ移ったあと(leaving を受けたあと)は失敗にしない。終わりは result・10 分の期限・次のクリックでの置き換え。
|
|
6278
6297
|
*/
|
|
6298
|
+
function gcRandomFlow() {
|
|
6299
|
+
var a = new Uint8Array(16);
|
|
6300
|
+
crypto.getRandomValues(a);
|
|
6301
|
+
var out = "";
|
|
6302
|
+
for (var i = 0; i < a.length; i++) out += (a[i] < 16 ? "0" : "") + a[i].toString(16);
|
|
6303
|
+
return out;
|
|
6304
|
+
}
|
|
6305
|
+
function gcWindowFeatures() {
|
|
6306
|
+
var w = 520, h = 680;
|
|
6307
|
+
var left = Math.max(0, Math.round((window.screenX || 0) + ((window.outerWidth || w) - w) / 2));
|
|
6308
|
+
var top = Math.max(0, Math.round((window.screenY || 0) + ((window.outerHeight || h) - h) / 3));
|
|
6309
|
+
return GC_FEATURES + ",left=" + left + ",top=" + top;
|
|
6310
|
+
}
|
|
6279
6311
|
function openGoogleConfirm(opts) {
|
|
6280
6312
|
return new Promise(function (resolve) {
|
|
6313
|
+
// 前の確認が残っていれば、窓が生きている間は前面に出すだけ。閉じていれば捨てて新しく始める
|
|
6314
|
+
if (gcPending) {
|
|
6315
|
+
try { if (gcPending.win && !gcPending.win.closed) { gcPending.win.focus(); resolve({ ok: false, message: "開いている Google の窓で続けてください" }); return; } } catch (e) { /* 捨てて続ける */ }
|
|
6316
|
+
gcPending.discard();
|
|
6317
|
+
}
|
|
6281
6318
|
var intake = SITE.intake;
|
|
6282
|
-
var
|
|
6319
|
+
var flow = gcRandomFlow();
|
|
6320
|
+
var win = window.open(new URL(GC_PAGE, location.href).href, "cortex-google-confirm", gcWindowFeatures());
|
|
6283
6321
|
if (!win) { resolve({ ok: false, message: "ポップアップが開けませんでした。ブラウザのポップアップを許可して、もう一度押してください" }); return; }
|
|
6284
|
-
var done = false;
|
|
6322
|
+
var done = false; // 後始末まで済んだ
|
|
6323
|
+
var resolved = false; // 呼び手へ結果を返した(Google にいる間に失敗として返しても、flow は期限まで生かす)
|
|
6324
|
+
var left = false;
|
|
6325
|
+
var resumes = 0;
|
|
6326
|
+
var readySent = false;
|
|
6285
6327
|
var last = null;
|
|
6328
|
+
var closedAt = 0;
|
|
6329
|
+
var channel = null;
|
|
6286
6330
|
var timer = null;
|
|
6287
|
-
|
|
6331
|
+
var startedAt = Date.now();
|
|
6332
|
+
function settle(r) {
|
|
6333
|
+
if (resolved) return;
|
|
6334
|
+
resolved = true;
|
|
6335
|
+
resolve(r);
|
|
6336
|
+
}
|
|
6337
|
+
function finish() {
|
|
6288
6338
|
if (done) return;
|
|
6289
6339
|
done = true;
|
|
6290
|
-
window.removeEventListener("message",
|
|
6340
|
+
window.removeEventListener("message", onOpenerMsg);
|
|
6341
|
+
try { if (channel) channel.close(); } catch (e) { /* 閉じ済み */ }
|
|
6291
6342
|
clearInterval(timer);
|
|
6292
|
-
|
|
6343
|
+
if (gcPending && gcPending.flow === flow) gcPending = null;
|
|
6293
6344
|
}
|
|
6294
|
-
function
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6345
|
+
function end(r) {
|
|
6346
|
+
settle(r);
|
|
6347
|
+
finish();
|
|
6348
|
+
}
|
|
6349
|
+
function sendStart(target) {
|
|
6350
|
+
// 名乗り券は、Google で時間がかかっても切れないよう、払い出すたびに作り直す
|
|
6351
|
+
target({
|
|
6352
|
+
type: GC_MSG.start,
|
|
6353
|
+
flow: flow,
|
|
6354
|
+
intake: { url: intake.url, token: intake.token },
|
|
6355
|
+
ticket: actorTicket(),
|
|
6356
|
+
kind: opts.kind,
|
|
6357
|
+
mode: opts.mode,
|
|
6358
|
+
url: opts.url || "",
|
|
6359
|
+
id: opts.id || "",
|
|
6360
|
+
});
|
|
6361
|
+
}
|
|
6362
|
+
function handle(data, replyTo) {
|
|
6363
|
+
if (done || !data || typeof data !== "object" || data.flow !== flow) return;
|
|
6364
|
+
closedAt = 0;
|
|
6365
|
+
if (data.type === GC_MSG.resume) {
|
|
6366
|
+
// 戻ってきた窓が指示をもらい直す(Google から戻るたび。上限つき)
|
|
6367
|
+
if (resumes < GC_START_MAX) { resumes += 1; sendStart(replyTo); }
|
|
6368
|
+
} else if (data.type === GC_MSG.leaving) left = true;
|
|
6369
|
+
else if (data.type === GC_MSG.result) {
|
|
6370
|
+
if (data.ok) {
|
|
6371
|
+
// 失敗として返したあとに成功が届いたら、一覧だけ取り直す
|
|
6372
|
+
if (resolved) afterGoogleConfirm();
|
|
6373
|
+
end({ ok: true, message: data.message, changed: data.changed });
|
|
6374
|
+
} else if (!resolved) last = { message: typeof data.message === "string" ? data.message : null };
|
|
6309
6375
|
}
|
|
6310
6376
|
}
|
|
6311
|
-
|
|
6377
|
+
function viaChannel(msg) { try { if (channel) channel.postMessage(msg); } catch (e) { /* 閉じている */ } }
|
|
6378
|
+
function viaWindow(msg) { try { win.postMessage(msg, location.origin); } catch (e) { /* 閉じている */ } }
|
|
6379
|
+
function onOpenerMsg(ev) {
|
|
6380
|
+
if (ev.origin !== location.origin || ev.source !== win || !ev.data) return;
|
|
6381
|
+
// 最初の ready だけは flow を持たない(開いたばかり)。1 回だけ指示を返す
|
|
6382
|
+
if (ev.data.type === GC_MSG.ready) { closedAt = 0; if (!readySent) { readySent = true; sendStart(viaWindow); } return; }
|
|
6383
|
+
// resume はチャンネルで受けるので、opener 経由の複製は捨てる(払い出しの二重消費を避ける)
|
|
6384
|
+
if (ev.data.type === GC_MSG.resume) return;
|
|
6385
|
+
handle(ev.data, viaWindow);
|
|
6386
|
+
}
|
|
6387
|
+
try {
|
|
6388
|
+
channel = new BroadcastChannel(GC_CHANNEL + flow);
|
|
6389
|
+
channel.onmessage = function (ev) { handle(ev.data, viaChannel); };
|
|
6390
|
+
} catch (e) { channel = null; }
|
|
6391
|
+
window.addEventListener("message", onOpenerMsg);
|
|
6312
6392
|
timer = setInterval(function () {
|
|
6313
|
-
|
|
6393
|
+
var now = Date.now();
|
|
6394
|
+
var failure = last && last.message ? last.message : GC_UNFINISHED;
|
|
6395
|
+
if (now - startedAt >= GC_TIMEOUT_MS) { end({ ok: false, message: failure }); return; }
|
|
6396
|
+
var closed = false;
|
|
6397
|
+
try { closed = win.closed; } catch (e) { closed = true; }
|
|
6398
|
+
if (!closed) { closedAt = 0; return; }
|
|
6399
|
+
if (!closedAt) closedAt = now;
|
|
6400
|
+
if (left) {
|
|
6401
|
+
// Google へ移ったあと: 窓の所属が切り替わって closed に見えることがある。呼び手には押し直せる形で返すが、
|
|
6402
|
+
// flow は期限まで生かす(窓が実は生きていて、戻って resume してくる場合に備える)
|
|
6403
|
+
if (now - closedAt >= GC_LEFT_GRACE_MS) settle({ ok: false, message: failure });
|
|
6404
|
+
} else if (now - closedAt >= GC_CLOSED_GRACE_MS) {
|
|
6405
|
+
end({ ok: false, message: failure });
|
|
6406
|
+
}
|
|
6314
6407
|
}, 500);
|
|
6408
|
+
gcPending = { flow: flow, win: win, discard: function () { end({ ok: false, message: GC_UNFINISHED }); } };
|
|
6315
6409
|
});
|
|
6316
6410
|
}
|
|
6317
6411
|
/** 確認が済んだあとの後始末: 登録の一覧と確認済みの一覧(どちらも op=state)を取り直し、開いている一覧を描き直す */
|
package/package.json
CHANGED