@takagaki/cortex-decisions-viewer 0.12.197 → 0.12.199

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.
@@ -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
- * 追加欄(ビューア本体)は、このページをポップアップで開く。Google のトークンは**このページの中だけ**で扱い、
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. ビューア本体が `window.open` で開く → このページが `ready` を返す → 本体が指示(`start`)を返す
13
- * (指示には intake の宛先・名乗り券・種別・追加か確認か・Chat の URL か確認したい行の ID が入る)
14
- * 2. [Google で確認する](利用者の操作)→ 承認用のウィンドウを**先に**開く(ポップアップを止められないため)
15
- * 3. intake に nonce をもらう(`google-nonce`)→ 承認用のウィンドウを Google の承認画面へ送る
16
- * (`response_type=id_token token`・`nonce`・`prompt=select_account`)
17
- * 4. 承認が終わると、ポータルの戻り先の頁(`/google-confirm-callback.html`)が結果を同じオリジンへ返す
18
- * (BroadcastChannel と opener の両方。`state` が合うものだけ受ける)
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
- * 6. 結果を本体へ返し、成功なら閉じる
21
+ * (nonce は返った id_token の `nonce` クレームから読む。サーバが検証済みトークンと台帳で突き合わせる)
22
+ * 6. 結果を本体へ返し、成功なら閉じる(閉じられなければ[閉じる])
21
23
  *
22
- * 🔴 トークン・nonce は変数に持つだけ。localStorage・URL・DOM・ログに書かない。戻り先の頁は受け取ったらすぐ URL の
23
- * フラグメントを消す。postMessage は送り元・送り先のオリジンを確かめる(同じオリジンだけ)。
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.GOOGLE_CONFIRM_CALLBACK_TYPE = exports.GOOGLE_CONFIRM_CHANNEL = exports.GOOGLE_CONFIRM_CALLBACK_PATH = exports.GOOGLE_CONFIRM_POST_TYPES = exports.GOOGLE_CONFIRM_PAGE_FILE = void 0;
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
- /** 本体とページの間の postMessage の型(本体の埋め込み JS にも同じ綴りを流し込む) */
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
- /** ポータルの戻り先の頁と、同じオリジンの中で結果を運ぶ BroadcastChannel の名前 */
46
+ /**
47
+ * ポータルの戻り先の頁と、窓が Google から戻ったあとに本体と再接続する BroadcastChannel の名前(`<名前><flow>`)。
48
+ * 旧版の戻り先の頁が `cortex-google-confirm`(トークン入りの hash)を流すので、別の名前にして本体の受け口に混ざらないようにする
49
+ */
42
50
  exports.GOOGLE_CONFIRM_CALLBACK_PATH = "/google-confirm-callback.html";
43
- exports.GOOGLE_CONFIRM_CHANNEL = "cortex-google-confirm";
44
- exports.GOOGLE_CONFIRM_CALLBACK_TYPE = "cortex-google-callback";
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 CHANNEL = ${JSON.stringify(exports.GOOGLE_CONFIRM_CHANNEL)};
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 CLOSED_GRACE_MS = 6000;
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");
@@ -73,6 +89,8 @@ function googleConfirmPageJs() {
73
89
  var closeBtn = $("gc-close");
74
90
 
75
91
  var ctx = null;
92
+ var flow = null;
93
+ var channel = null;
76
94
  var busy = false;
77
95
  var finished = false;
78
96
 
@@ -80,38 +98,52 @@ function googleConfirmPageJs() {
80
98
  statusEl.textContent = text || "";
81
99
  statusEl.className = "gc-status" + (cls ? " " + cls : "");
82
100
  }
83
- function randomText(bytes) {
84
- var a = new Uint8Array(bytes);
85
- crypto.getRandomValues(a);
86
- var out = "";
87
- for (var i = 0; i < a.length; i++) out += (a[i] < 16 ? "0" : "") + a[i].toString(16);
88
- return out;
101
+ function b64urlEncode(text) {
102
+ return btoa(text).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
89
103
  }
90
- /** 本体へ結果だけを返す(トークンは載せない)。同じオリジンの opener にだけ */
91
- function reportToOpener(result) {
104
+ /** 本体へ送る。同じ BroadcastChannel(flow 入りの名前)と、生きていれば opener の両方(オリジンは同じものだけ) */
105
+ function send(message, channelOnly) {
106
+ var body = Object.assign({ flow: flow }, message);
107
+ try { if (channel) channel.postMessage(body); } catch (e) { /* 閉じている */ }
108
+ if (channelOnly) return;
92
109
  try {
93
- if (window.opener && !window.opener.closed) {
94
- window.opener.postMessage(Object.assign({ type: MSG.result }, result), ORIGIN);
95
- }
110
+ if (window.opener && !window.opener.closed) window.opener.postMessage(body, ORIGIN);
96
111
  } catch (e) { /* 本体が閉じていても、このページの表示は残す */ }
97
112
  }
98
- /** 成功: 閉じる。本体へ結果を返す */
113
+ function openChannel() {
114
+ if (channel || !flow) return;
115
+ try {
116
+ channel = new BroadcastChannel(CHANNEL_PREFIX + flow);
117
+ channel.onmessage = function (ev) { acceptStart(ev.data); };
118
+ } catch (e) { channel = null; }
119
+ }
120
+ /** 成功: 本体へ結果を返し、閉じる(閉じられなければ[閉じる]と案内が残る) */
99
121
  function succeed(message, extra) {
100
122
  finished = true;
101
123
  busy = false;
102
124
  goBtn.hidden = true;
103
125
  closeBtn.hidden = false;
104
- setStatus("✓ " + message, "is-ok");
105
- reportToOpener(Object.assign({ ok: true, message: message }, extra || {}));
126
+ setStatus("✓ " + message + "。このウィンドウは閉じて構いません", "is-ok");
127
+ send(Object.assign({ type: MSG.result, ok: true, message: message }, extra || {}));
106
128
  setTimeout(function () { try { window.close(); } catch (e) { /* 閉じられなくても表示は残る */ } }, 1200);
107
129
  }
108
130
  /** 失敗: 理由を出し、押し直せるように戻す(別のアカウントでやり直す人のため)。本体にも理由を渡す */
109
131
  function retryable(message, extra) {
110
132
  busy = false;
133
+ goBtn.hidden = false;
111
134
  goBtn.disabled = false;
112
135
  goBtn.textContent = "もう一度 Google で確認する";
136
+ closeBtn.hidden = false;
137
+ setStatus("✗ " + message, "is-error");
138
+ send(Object.assign({ type: MSG.result, ok: false, message: message }, extra || {}));
139
+ }
140
+ /** 続けられない(本体が見つからない・結果が読めない): 理由と[閉じる]だけ */
141
+ function dead(message) {
142
+ busy = false;
143
+ finished = true;
144
+ goBtn.hidden = true;
145
+ closeBtn.hidden = false;
113
146
  setStatus("✗ " + message, "is-error");
114
- reportToOpener(Object.assign({ ok: false, message: message }, extra || {}));
115
147
  }
116
148
 
117
149
  /** intake を 1 回呼ぶ。戻り値は { status, body }(body は JSON か、文字列のとき { message } に直す) */
@@ -133,50 +165,11 @@ function googleConfirmPageJs() {
133
165
  var intake = ctx.intake;
134
166
  var url = intake.url + (intake.url.indexOf("?") === -1 ? "?" : "&") + "op=google-confirm-info&t=" + encodeURIComponent(intake.token);
135
167
  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
- /** 承認の結果を待つ。BroadcastChannel と opener の両方を聞き、state が合うものだけを受ける */
140
- function waitForCallback(state, authWin) {
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
- });
168
+ .then(function (res) { return res.json().catch(function () { return {}; }); })
169
+ .then(function (i) {
170
+ if (!i || i.enabled !== true || i.ready !== true) throw { message: UNAVAILABLE };
171
+ return i;
172
+ });
180
173
  }
181
174
 
182
175
  function authUrl(info, nonce, state) {
@@ -192,6 +185,15 @@ function googleConfirmPageJs() {
192
185
  ];
193
186
  return "https://accounts.google.com/o/oauth2/v2/auth?" + q.map(function (kv) { return kv[0] + "=" + encodeURIComponent(kv[1]); }).join("&");
194
187
  }
188
+ /** 返った id_token の nonce クレーム(検証はサーバ。ここは送り返すために読むだけ) */
189
+ function nonceOfIdToken(idToken) {
190
+ try {
191
+ var payload = idToken.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
192
+ var nonce = JSON.parse(atob(payload)).nonce;
193
+ if (typeof nonce === "string" && nonce) return nonce;
194
+ } catch (e) { /* 読めない */ }
195
+ throw { message: UNFINISHED };
196
+ }
195
197
 
196
198
  function loadScript(src) {
197
199
  return new Promise(function (resolve, reject) {
@@ -235,22 +237,21 @@ function googleConfirmPageJs() {
235
237
  }
236
238
  return UNFINISHED;
237
239
  }
240
+ function failureMessage(e) {
241
+ // 通信そのものの失敗(Error)の英語の文面は画面に出さない
242
+ return Object.prototype.toString.call(e) === "[object Error]" ? UNAVAILABLE : e && e.message ? e.message : UNFINISHED;
243
+ }
238
244
 
239
- /** [Google で確認する]を押したあとの全部 */
240
- function run() {
245
+ /** 🔴 クリック無しで進む: nonce をもらい、この窓そのものを Google の承認画面へ移す */
246
+ function go() {
241
247
  if (busy || finished || !ctx) return;
242
248
  busy = true;
243
- goBtn.disabled = true;
244
- setStatus("Google の画面で確認してください…", "");
245
- // 🔴 承認用のウィンドウは、押した直後(同期)に開く。後ろへ回すとポップアップとして止められる
246
- var authWin = window.open("about:blank", "cortex-google-auth", "popup,width=500,height=680");
247
- if (!authWin) { retryable("ポップアップが開けませんでした。ブラウザのポップアップを許可して、もう一度押してください"); return; }
249
+ goBtn.hidden = true;
250
+ closeBtn.hidden = true;
251
+ setStatus("Google の画面へ移動しています…", "");
248
252
  var info = null;
249
- var nonce = null;
250
- var tokens = null;
251
253
  loadInfo()
252
254
  .then(function (i) {
253
- if (!i || i.enabled !== true || i.ready !== true) throw { message: "今は確認できません。しばらくしてからもう一度" };
254
255
  info = i;
255
256
  var body = { op: "google-nonce", kind: ctx.kind };
256
257
  if (ctx.kind === "google-chat") body.url = ctx.url || ctx.id;
@@ -258,24 +259,38 @@ function googleConfirmPageJs() {
258
259
  return callIntake(body);
259
260
  })
260
261
  .then(function (r) {
261
- if (r.status !== 200 || !r.body.nonce) throw { message: r.body.message || "今は確認できません。しばらくしてからもう一度" };
262
- nonce = r.body.nonce;
263
- var state = randomText(16);
264
- var waiting = waitForCallback(state, authWin);
265
- authWin.location.href = authUrl(info, nonce, state);
266
- return waiting;
262
+ if (r.status !== 200 || !r.body.nonce) throw { message: r.body.message || UNAVAILABLE };
263
+ var state;
264
+ try { state = flow + "." + b64urlEncode(location.pathname); } catch (e) { throw { message: UNAVAILABLE }; }
265
+ // 本体へ「これから Google へ移る」と知らせる(窓の見張りを、Google にいる間は失敗にしないため)
266
+ send({ type: MSG.leaving });
267
+ location.replace(authUrl(info, r.body.nonce, state));
267
268
  })
268
- .then(function (cb) {
269
- try { authWin.close(); } catch (e) { /* もう閉じている */ }
270
- if (cb.closed) throw { message: UNFINISHED };
271
- var p = cb.params;
272
- if (p.get("error")) throw { message: authErrorMessage(p.get("error")) };
273
- var access = p.get("access_token");
274
- var idToken = p.get("id_token");
269
+ .catch(function (e) { retryable(failureMessage(e)); });
270
+ }
271
+
272
+ /** Google から戻ったあと: フラグメントのトークンで、Picker(Drive)→ op を 1 回 */
273
+ function afterReturn(params) {
274
+ if (busy || finished || !ctx) return;
275
+ busy = true;
276
+ goBtn.hidden = true;
277
+ closeBtn.hidden = true;
278
+ setStatus("確認しています…", "");
279
+ var tokens = null;
280
+ var info = null;
281
+ Promise.resolve()
282
+ .then(function () {
283
+ if (params.get("error")) throw { message: authErrorMessage(params.get("error")) };
284
+ var access = params.get("access_token");
285
+ var idToken = params.get("id_token");
275
286
  if (!access || !idToken) throw { message: UNFINISHED };
276
- tokens = { googleToken: access, googleIdToken: idToken, googleNonce: nonce };
287
+ tokens = { googleToken: access, googleIdToken: idToken, googleNonce: nonceOfIdToken(idToken) };
288
+ return loadInfo();
289
+ })
290
+ .then(function (i) {
291
+ info = i;
277
292
  // Drive の追加と確認は、承認のあとに本人が Picker で選ぶ(drive.file はここで選んだものだけが読める)
278
- if (ctx.kind === "drive") return pickFolder(info, access);
293
+ if (ctx.kind === "drive") return pickFolder(info, tokens.googleToken);
279
294
  return null;
280
295
  })
281
296
  .then(function (picked) {
@@ -302,20 +317,17 @@ function googleConfirmPageJs() {
302
317
  })
303
318
  .catch(function (e) {
304
319
  tokens = null;
305
- try { authWin.close(); } catch (e2) { /* もう閉じている */ }
306
- // 通信そのものの失敗(Error)の英語の文面は画面に出さない
307
- var message = Object.prototype.toString.call(e) === "[object Error]" ? "今は確認できません。しばらくしてからもう一度" : e && e.message ? e.message : UNFINISHED;
308
- retryable(message);
320
+ retryable(failureMessage(e));
309
321
  });
310
322
  }
311
323
 
312
- function onStart(ev) {
313
- // 🔴 同じオリジンの、このページを開いた本体からの指示だけを受ける
314
- if (ev.origin !== ORIGIN || ev.source !== window.opener) return;
315
- var d = ev.data;
324
+ /** 本体からの指示(opener か BroadcastChannel)。flow が合うものだけを 1 回だけ受ける */
325
+ function acceptStart(d) {
316
326
  if (!d || d.type !== MSG.start || ctx) return;
327
+ if (typeof d.flow !== "string" || !FLOW_RE.test(d.flow) || (flow && d.flow !== flow)) return;
317
328
  if (!d.intake || typeof d.intake.url !== "string" || typeof d.intake.token !== "string") return;
318
329
  if (d.kind !== "drive" && d.kind !== "google-chat") return;
330
+ flow = d.flow;
319
331
  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
332
  var isDrive = ctx.kind === "drive";
321
333
  var what = isDrive ? "Drive のフォルダ" : "Google Chat のスペース";
@@ -323,16 +335,48 @@ function googleConfirmPageJs() {
323
335
  leadEl.textContent = isDrive
324
336
  ? "あなたの Google アカウントで、追加するフォルダを選びます。選んだフォルダだけを Cortex が確認に使います。「このアプリは Google で確認されていません」と出たら、「詳細」から進めます。"
325
337
  : "あなたの Google アカウントで、このスペースを開けるかを確認します。メッセージは読みません。";
326
- goBtn.textContent = "Google で確認する";
327
- goBtn.hidden = false;
328
- setStatus("", "");
338
+ openChannel();
339
+ // 作業中(Picker の選択など)は本体へ生存の合図を送る。本体は、窓が閉じたように見えても、合図が続いている間は失敗にしない
340
+ setInterval(function () { if (!finished) send({ type: MSG.alive }, true); }, 3000);
341
+ if (returned) afterReturn(returned.params);
342
+ else go();
343
+ }
344
+ function onOpenerMessage(ev) {
345
+ // 🔴 同じオリジンの、このページを開いた本体からの指示だけを受ける
346
+ if (ev.origin !== ORIGIN || ev.source !== window.opener) return;
347
+ acceptStart(ev.data);
329
348
  }
330
349
 
331
- goBtn.addEventListener("click", run);
350
+ /** 戻ってきたとき: state から flow を取り出す(取れなければ続けられない) */
351
+ var returned = null;
352
+ if (RETURNED_HASH) {
353
+ var params = new URLSearchParams(RETURNED_HASH.replace(/^#/, ""));
354
+ var returnedState = params.get("state") || "";
355
+ var returnedFlow = returnedState.split(".")[0];
356
+ if (FLOW_RE.test(returnedFlow)) returned = { params: params, flow: returnedFlow };
357
+ }
358
+
359
+ goBtn.addEventListener("click", function () { go(); });
332
360
  closeBtn.addEventListener("click", function () { try { window.close(); } catch (e) { /* 閉じられなければそのまま */ } });
333
- window.addEventListener("message", onStart);
334
- // このページは本体から開かれたときだけ使える
335
- if (window.opener) {
361
+ window.addEventListener("message", onOpenerMessage);
362
+
363
+ if (returned) {
364
+ // 窓が Google から戻ってきた。opener は切れていることがあるので、flow 入りのチャンネルで本体を呼び、指示をもらう
365
+ flow = returned.flow;
366
+ openChannel();
367
+ setStatus("確認の続きを準備しています…", "");
368
+ var asked = 0;
369
+ var timer = setInterval(function () {
370
+ if (ctx) { clearInterval(timer); return; }
371
+ asked += 1;
372
+ if (asked * 1000 > RESUME_WAIT_MS) { clearInterval(timer); dead(NO_PARENT); return; }
373
+ send({ type: MSG.resume }, true);
374
+ }, 1000);
375
+ send({ type: MSG.resume }, true);
376
+ } else if (RETURNED_HASH) {
377
+ dead("確認の結果を読み取れませんでした。追加欄からもう一度やり直してください");
378
+ } else if (window.opener) {
379
+ // このページは本体から開かれたときだけ使える
336
380
  try { window.opener.postMessage({ type: MSG.ready }, ORIGIN); } catch (e) { /* 本体が閉じた */ }
337
381
  } else {
338
382
  setStatus("このページは、追加欄の[Google で選ぶ]などから開きます。", "");