pinokiod 7.5.15 → 7.5.16

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,7 +1,301 @@
1
- const path = require('path')
2
1
  const unparse = require('yargs-unparser-custom-flag');
3
2
  const Shell = require('../shell')
3
+ const Util = require('../../util')
4
+ const HtmlModal = require('../htmlmodal')
5
+
6
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
7
+ const positiveNumber = (value, fallback) => {
8
+ const number = Number(value)
9
+ return Number.isFinite(number) && number > 0 ? number : fallback
10
+ }
11
+ const escapeHtml = (value) => String(value == null ? "" : value)
12
+ .replace(/&/g, "&")
13
+ .replace(/</g, "&lt;")
14
+ .replace(/>/g, "&gt;")
15
+ .replace(/"/g, "&quot;")
16
+ .replace(/'/g, "&#39;")
17
+
4
18
  class HF {
19
+ constructor() {
20
+ this.htmlModal = new HtmlModal()
21
+ }
22
+ connect(kernel, method) {
23
+ if (!kernel || !kernel.connect || typeof kernel.connect[method] !== "function") {
24
+ throw new Error(`Hugging Face connect ${method} is not available`)
25
+ }
26
+ return kernel.connect
27
+ }
28
+ safeKeys(keys) {
29
+ if (!keys || !keys.access_token) {
30
+ return keys
31
+ }
32
+ return {
33
+ token_path: keys.token_path
34
+ }
35
+ }
36
+ async copyCode(login, ondata, options = {}) {
37
+ if (!login || !login.user_code) {
38
+ return null
39
+ }
40
+ try {
41
+ await Util.clipboard({
42
+ type: "copy",
43
+ text: login.user_code
44
+ })
45
+ if (ondata && !options.silent) {
46
+ ondata({ raw: `\r\nHugging Face code copied to clipboard: ${login.user_code}\r\n` })
47
+ }
48
+ return { ok: true }
49
+ } catch (error) {
50
+ const message = error && error.message ? error.message : String(error)
51
+ if (ondata && !options.silent) {
52
+ ondata({ raw: `\r\nCopy this Hugging Face code into the browser: ${login.user_code}\r\n` })
53
+ ondata({ raw: `Clipboard copy failed: ${message}\r\n` })
54
+ }
55
+ return { ok: false, error: message }
56
+ }
57
+ }
58
+ canDispatchModal(req, ondata, kernel) {
59
+ return !!(
60
+ ondata
61
+ && kernel
62
+ && kernel.api
63
+ && typeof kernel.api.wait === "function"
64
+ && req
65
+ && req.parent
66
+ && (req.parent.id || req.parent.path)
67
+ )
68
+ }
69
+ canUseModal(req, ondata, kernel, url) {
70
+ return !!(url && this.canDispatchModal(req, ondata, kernel))
71
+ }
72
+ modalRequest(req, modalId, params = {}) {
73
+ return {
74
+ params: Object.assign({ id: modalId }, params),
75
+ parent: req.parent,
76
+ cwd: req.cwd
77
+ }
78
+ }
79
+ modalId(req) {
80
+ const id = req && req.parent ? (req.parent.id || req.parent.path) : "hf-login"
81
+ return `hf-login:${id}`
82
+ }
83
+ loginModalHtml(login, clipboard) {
84
+ const code = escapeHtml(login.user_code || "")
85
+ const expires = login.expires_in
86
+ ? `<p class="hf-login-modal-note">Expires in ${escapeHtml(login.expires_in)} seconds.</p>`
87
+ : ""
88
+ let copyText = "Copy this code if Hugging Face asks for it."
89
+ let copyClass = "neutral"
90
+ if (clipboard && clipboard.ok) {
91
+ copyText = "The code has been copied to your clipboard."
92
+ copyClass = "success"
93
+ } else if (clipboard && clipboard.ok === false) {
94
+ copyText = `Clipboard copy failed. The code is displayed below.`
95
+ copyClass = "warning"
96
+ }
97
+ return `
98
+ <div class="hf-login-modal">
99
+ <div class="hf-login-modal-copy ${copyClass}">${escapeHtml(copyText)}</div>
100
+ <div class="hf-login-modal-code" aria-label="Hugging Face device code">${code}</div>
101
+ ${expires}
102
+ <p>Open Hugging Face to finish login. Pinokio will continue automatically.</p>
103
+ </div>
104
+ `
105
+ }
106
+ async promptLoginModal(req, ondata, kernel, login, clipboard) {
107
+ const modalId = this.modalId(req)
108
+ const response = await this.htmlModal.open(
109
+ this.modalRequest(req, modalId, {
110
+ title: "Log in to Hugging Face",
111
+ variant: "minimal",
112
+ html: this.loginModalHtml(login, clipboard),
113
+ status: { text: "Waiting for you to open Hugging Face.", waiting: false },
114
+ actions: [{
115
+ id: "open",
116
+ label: "Open Hugging Face",
117
+ type: "submit",
118
+ href: login.verification_uri_complete,
119
+ target: "_blank",
120
+ features: "browser",
121
+ primary: true,
122
+ close: false,
123
+ icon: "fa-solid fa-arrow-up-right-from-square"
124
+ }, {
125
+ id: "cancel",
126
+ label: "Cancel",
127
+ type: "submit",
128
+ variant: "secondary"
129
+ }],
130
+ actionsAlign: "end",
131
+ await: true,
132
+ dismissible: false
133
+ }),
134
+ ondata,
135
+ kernel
136
+ )
137
+
138
+ if (!response || response.action === "cancel" || response.action === "dismissed") {
139
+ await this.htmlModal.close(this.modalRequest(req, modalId), ondata, kernel)
140
+ throw new Error("Hugging Face login canceled.")
141
+ }
142
+
143
+ await this.htmlModal.update(
144
+ this.modalRequest(req, modalId, {
145
+ variant: "minimal",
146
+ status: { text: "Waiting for Hugging Face authorization to finish...", waiting: true },
147
+ actions: [{
148
+ id: "open-again",
149
+ label: "Open Again",
150
+ type: "link",
151
+ href: login.verification_uri_complete,
152
+ target: "_blank",
153
+ features: "browser",
154
+ variant: "secondary",
155
+ icon: "fa-solid fa-arrow-up-right-from-square"
156
+ }],
157
+ actionsAlign: "end",
158
+ dismissible: false
159
+ }),
160
+ ondata,
161
+ kernel
162
+ )
163
+ return response
164
+ }
165
+ async closeLoginModal(req, ondata, kernel) {
166
+ if (!this.canDispatchModal(req, ondata, kernel)) {
167
+ return
168
+ }
169
+ await this.htmlModal.close(this.modalRequest(req, this.modalId(req)), ondata, kernel)
170
+ }
171
+ async cancelLogin(connect, params) {
172
+ if (connect && typeof connect.cancelLogin === "function") {
173
+ await connect.cancelLogin("huggingface", params || {})
174
+ }
175
+ }
176
+ /*
177
+ {
178
+ "method": "hf.login",
179
+ "params": {
180
+ "force": false,
181
+ "open": true,
182
+ "modal": true,
183
+ "clipboard": true,
184
+ "wait": true,
185
+ "timeout": 120000,
186
+ "interval": 2000
187
+ }
188
+ }
189
+
190
+ Starts the existing Hugging Face device login provider. By default it shows a
191
+ blocking modal with the copied code and waits for the user to open the
192
+ verification URL before polling for the managed token.
193
+ */
194
+ async login(req, ondata, kernel) {
195
+ const connect = this.connect(kernel, "keys")
196
+ const params = req.params || {}
197
+ const force = params.force === true
198
+ const shouldOpen = params.open !== false
199
+ const shouldModal = params.modal !== false
200
+ const shouldCopy = params.clipboard !== false
201
+ const shouldWait = params.wait !== false
202
+ const timeout = positiveNumber(params.timeout, 120000)
203
+ const interval = positiveNumber(params.interval, 2000)
204
+
205
+ if (!force) {
206
+ const existing = await connect.keys("huggingface")
207
+ if (existing && existing.access_token) {
208
+ return {
209
+ status: "success",
210
+ already_logged_in: true,
211
+ ...this.safeKeys(existing)
212
+ }
213
+ }
214
+ }
215
+
216
+ if (typeof connect.login !== "function") {
217
+ throw new Error("Hugging Face connect login is not available")
218
+ }
219
+ const response = await connect.login("huggingface", params)
220
+ if (!response || response.status === "error") {
221
+ return response
222
+ }
223
+
224
+ const login = response.login || {}
225
+ const url = login.verification_uri_complete
226
+ const useModal = shouldModal && this.canUseModal(req, ondata, kernel, url)
227
+ if (shouldCopy && login.user_code) {
228
+ response.clipboard = await this.copyCode(login, ondata, { silent: useModal })
229
+ } else if (login.user_code && ondata && !useModal) {
230
+ ondata({ raw: `\r\nHugging Face code: ${login.user_code}\r\n` })
231
+ }
232
+
233
+ if (useModal) {
234
+ try {
235
+ response.open = await this.promptLoginModal(req, ondata, kernel, login, response.clipboard)
236
+ } catch (error) {
237
+ await this.cancelLogin(connect, params)
238
+ throw error
239
+ }
240
+ } else if (shouldOpen && url) {
241
+ if (ondata) {
242
+ if (!shouldModal) {
243
+ ondata({ raw: "\r\nHugging Face login modal disabled.\r\n" })
244
+ }
245
+ const codeHint = response.clipboard && response.clipboard.ok
246
+ ? "The code is already on your clipboard; paste it if Hugging Face asks."
247
+ : "If Hugging Face asks for a code, use the code printed above."
248
+ ondata({ raw: `Opening Hugging Face login: ${url}\r\n${codeHint}\r\n` })
249
+ if (response.clipboard && response.clipboard.ok === false) {
250
+ ondata({ raw: "The browser is opening even though clipboard copy failed.\r\n" })
251
+ }
252
+ }
253
+ response.open = await Util.openURI(url)
254
+ }
255
+
256
+ if (!shouldWait) {
257
+ if (useModal) {
258
+ await this.closeLoginModal(req, ondata, kernel)
259
+ }
260
+ return response
261
+ }
262
+
263
+ const startedAt = Date.now()
264
+ while (Date.now() - startedAt < timeout) {
265
+ const keys = await connect.keys("huggingface")
266
+ if (keys && keys.access_token) {
267
+ if (useModal) {
268
+ await this.closeLoginModal(req, ondata, kernel)
269
+ }
270
+ return {
271
+ ...response,
272
+ status: "success",
273
+ ...this.safeKeys(keys)
274
+ }
275
+ }
276
+ await delay(interval)
277
+ }
278
+
279
+ if (useModal) {
280
+ await this.closeLoginModal(req, ondata, kernel)
281
+ }
282
+ await this.cancelLogin(connect, params)
283
+ return {
284
+ ...response,
285
+ status: "timeout",
286
+ error: "Timed out waiting for Hugging Face login to finish."
287
+ }
288
+ }
289
+ /*
290
+ {
291
+ "method": "hf.logout"
292
+ }
293
+ */
294
+ async logout(req, ondata, kernel) {
295
+ const connect = this.connect(kernel, "logout")
296
+ await connect.logout("huggingface", req.params || {})
297
+ return { status: "success" }
298
+ }
5
299
  /*
6
300
  {
7
301
  "method": "hf.download",
@@ -32,6 +32,12 @@ class Connect {
32
32
  let res = await this.clients[provider].logout(req)
33
33
  return res
34
34
  }
35
+ async cancelLogin(provider, req) {
36
+ if (this.clients[provider] && this.clients[provider].cancelLogin) {
37
+ return await this.clients[provider].cancelLogin(req)
38
+ }
39
+ return null
40
+ }
35
41
  async keys(provider) {
36
42
  let res = await this.clients[provider].keys()
37
43
  return res
@@ -227,6 +227,20 @@ class Huggingface {
227
227
  await this.runHf(["auth", "logout"]).catch(() => {})
228
228
  await fs.promises.rm(this.kernel.path("connect", "huggingface"), { recursive: true, force: true }).catch(() => {})
229
229
  }
230
+ async cancelLogin() {
231
+ const session = this.loginSession
232
+ if (!session || session.status !== "pending") {
233
+ return
234
+ }
235
+ session.status = "canceled"
236
+ session.error = "Hugging Face login canceled."
237
+ if (session.child) {
238
+ session.child.kill()
239
+ }
240
+ if (this.loginSession === session) {
241
+ this.loginSession = null
242
+ }
243
+ }
230
244
  async logout() {
231
245
  await this.destroy()
232
246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "7.5.15",
3
+ "version": "7.5.16",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -15,21 +15,43 @@
15
15
  style.id = 'htmlmodal-style'
16
16
  style.textContent = `
17
17
  .htmlmodal-overlay { position: fixed; inset: 0; width: 100vw; height: 100vh; background: rgba(15, 23, 42, 0.72); backdrop-filter: blur(6px); z-index: 1000000000005; padding: 32px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 0.18s ease, visibility 0.18s ease; }
18
+ .htmlmodal-overlay.minimal { background: rgba(9, 11, 15, 0.34); backdrop-filter: none; padding: 24px; }
19
+ body.dark .htmlmodal-overlay.minimal { background: rgba(0, 0, 0, 0.58); }
18
20
  .htmlmodal-overlay.visible { opacity: 1; visibility: visible; pointer-events: auto; }
19
21
  .htmlmodal-window { width: min(560px, 100%); max-height: calc(100vh - 64px); background: #0f172a; color: #f8fafc; border-radius: 18px; box-shadow: 0 30px 60px rgba(0,0,0,0.45); display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgba(148,163,184,0.2); }
22
+ .htmlmodal-window.minimal { width: min(420px, calc(100vw - 32px)); max-height: calc(100vh - 48px); background: #ffffff; color: #18181b; border-radius: 8px; box-shadow: 0 18px 50px rgba(15, 23, 42, 0.16); border-color: rgba(24, 24, 27, 0.12); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
23
+ body.dark .htmlmodal-window.minimal { background: #1b1c1d; color: rgba(250, 250, 250, 0.94); border-color: rgba(255, 255, 255, 0.1); box-shadow: 0 24px 72px rgba(0, 0, 0, 0.42); }
20
24
  .htmlmodal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px 12px; }
25
+ .htmlmodal-window.minimal .htmlmodal-header { gap: 16px; padding: 20px 20px 12px; }
21
26
  .htmlmodal-title { font-size: 1.15rem; font-weight: 600; }
27
+ .htmlmodal-window.minimal .htmlmodal-title { font-size: 16px; line-height: 1.25; letter-spacing: 0; }
22
28
  .htmlmodal-close { background: none; border: none; color: #cbd5f5; font-size: 20px; cursor: pointer; padding: 4px 8px; border-radius: 6px; }
23
29
  .htmlmodal-close:hover { background: rgba(255,255,255,0.08); }
30
+ .htmlmodal-window.minimal .htmlmodal-close { width: 28px; height: 28px; margin: -6px -6px 0 0; background: transparent; border: 1px solid transparent; color: #71717a; font: inherit; font-size: 18px; line-height: 1; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
31
+ .htmlmodal-window.minimal .htmlmodal-close:hover, .htmlmodal-window.minimal .htmlmodal-close:focus-visible { background: rgba(24, 24, 27, 0.06); color: #18181b; outline: none; }
32
+ body.dark .htmlmodal-window.minimal .htmlmodal-close { color: rgba(229, 231, 235, 0.58); }
33
+ body.dark .htmlmodal-window.minimal .htmlmodal-close:hover, body.dark .htmlmodal-window.minimal .htmlmodal-close:focus-visible { background: rgba(255, 255, 255, 0.07); color: rgba(250, 250, 250, 0.94); }
24
34
  .htmlmodal-body { padding: 0 24px 16px; overflow-y: auto; font-size: 0.95rem; line-height: 1.5; }
35
+ .htmlmodal-window.minimal .htmlmodal-body { padding: 0 20px; color: #71717a; font-size: 13px; line-height: 1.45; }
36
+ body.dark .htmlmodal-window.minimal .htmlmodal-body { color: rgba(229, 231, 235, 0.62); }
25
37
  .htmlmodal-body p { margin: 0 0 0.85rem; }
38
+ .htmlmodal-window.minimal .htmlmodal-body p { margin: 0 0 10px; }
26
39
  .htmlmodal-status { padding: 0 24px 18px; font-size: 0.9rem; color: #cbd5f5; display: flex; align-items: center; gap: 10px; }
40
+ .htmlmodal-window.minimal .htmlmodal-status { padding: 12px 20px 0; font-size: 13px; line-height: 1.4; color: #71717a; gap: 8px; }
41
+ body.dark .htmlmodal-window.minimal .htmlmodal-status { color: rgba(229, 231, 235, 0.62); }
27
42
  .htmlmodal-status.hidden { display: none; }
28
43
  .htmlmodal-status.error { color: #fecaca; }
29
44
  .htmlmodal-status.success { color: #bbf7d0; }
45
+ .htmlmodal-window.minimal .htmlmodal-status.error { color: #b91c1c; }
46
+ .htmlmodal-window.minimal .htmlmodal-status.success { color: #15803d; }
47
+ body.dark .htmlmodal-window.minimal .htmlmodal-status.error { color: #fca5a5; }
48
+ body.dark .htmlmodal-window.minimal .htmlmodal-status.success { color: #86efac; }
30
49
  .htmlmodal-status .spinner { width: 18px; height: 18px; border: 2px solid rgba(248, 250, 252, 0.2); border-top-color: #38bdf8; border-radius: 50%; animation: htmlmodal-spin 0.9s linear infinite; }
50
+ .htmlmodal-window.minimal .htmlmodal-status .spinner { width: 14px; height: 14px; border-color: rgba(24, 24, 27, 0.14); border-top-color: #18181b; }
51
+ body.dark .htmlmodal-window.minimal .htmlmodal-status .spinner { border-color: rgba(255, 255, 255, 0.16); border-top-color: rgba(250, 250, 250, 0.94); }
31
52
  @keyframes htmlmodal-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
32
53
  .htmlmodal-actions { padding: 0 24px 24px; display: flex; flex-wrap: wrap; gap: 10px; }
54
+ .htmlmodal-window.minimal .htmlmodal-actions { padding: 16px 20px 20px; gap: 8px; justify-content: flex-end; }
33
55
  .htmlmodal-actions .btn { border-radius: 999px; border: 1px solid transparent; padding: 10px 18px; font-size: 0.9rem; cursor: pointer; font-weight: 500; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; transition: transform 0.15s ease, background 0.2s ease, color 0.2s ease; }
34
56
  .htmlmodal-actions .btn.primary { background: #38bdf8; color: #0f172a; }
35
57
  .htmlmodal-actions .btn.primary:hover { background: #0ea5e9; transform: translateY(-1px); }
@@ -37,7 +59,31 @@
37
59
  .htmlmodal-actions .btn.secondary:hover { background: rgba(148,163,184,0.3); }
38
60
  .htmlmodal-actions .btn.link { background: rgba(56,189,248,0.12); border-color: rgba(56,189,248,0.55); color: #38bdf8; padding: 10px 18px; }
39
61
  .htmlmodal-actions .btn.link:hover { background: rgba(56,189,248,0.2); color: #7dd3fc; transform: translateY(-1px); }
62
+ .htmlmodal-window.minimal .htmlmodal-actions .btn { min-height: 32px; border-radius: 6px; border: 1px solid rgba(24, 24, 27, 0.12); padding: 0 12px; background: transparent; color: #52525b; font: inherit; font-size: 13px; font-weight: 600; justify-content: center; box-shadow: none; transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease; }
63
+ .htmlmodal-window.minimal .htmlmodal-actions .btn.primary { background: #18181b; border-color: #18181b; color: #ffffff; }
64
+ .htmlmodal-window.minimal .htmlmodal-actions .btn.primary:hover, .htmlmodal-window.minimal .htmlmodal-actions .btn.primary:focus-visible { background: #27272a; border-color: #27272a; outline: none; transform: none; }
65
+ .htmlmodal-window.minimal .htmlmodal-actions .btn.secondary:hover, .htmlmodal-window.minimal .htmlmodal-actions .btn.secondary:focus-visible, .htmlmodal-window.minimal .htmlmodal-actions .btn.link:hover, .htmlmodal-window.minimal .htmlmodal-actions .btn.link:focus-visible { background: rgba(24, 24, 27, 0.06); color: #18181b; outline: none; transform: none; }
66
+ .htmlmodal-window.minimal .htmlmodal-actions .btn.link { color: #52525b; }
67
+ body.dark .htmlmodal-window.minimal .htmlmodal-actions .btn { border-color: rgba(255, 255, 255, 0.1); color: rgba(229, 231, 235, 0.7); }
68
+ body.dark .htmlmodal-window.minimal .htmlmodal-actions .btn.primary { background: rgba(250, 250, 250, 0.94); border-color: rgba(250, 250, 250, 0.94); color: #1b1c1d; }
69
+ body.dark .htmlmodal-window.minimal .htmlmodal-actions .btn.primary:hover, body.dark .htmlmodal-window.minimal .htmlmodal-actions .btn.primary:focus-visible { background: #ffffff; border-color: #ffffff; color: #18181b; }
70
+ body.dark .htmlmodal-window.minimal .htmlmodal-actions .btn.secondary:hover, body.dark .htmlmodal-window.minimal .htmlmodal-actions .btn.secondary:focus-visible, body.dark .htmlmodal-window.minimal .htmlmodal-actions .btn.link:hover, body.dark .htmlmodal-window.minimal .htmlmodal-actions .btn.link:focus-visible { background: rgba(255, 255, 255, 0.07); color: rgba(250, 250, 250, 0.94); }
40
71
  .htmlmodal-actions .btn:disabled { opacity: 0.6; cursor: not-allowed; }
72
+ .hf-login-modal { display: flex; flex-direction: column; gap: 10px; }
73
+ .hf-login-modal p { margin: 0; }
74
+ .hf-login-modal-copy { font-size: 12px; color: #71717a; }
75
+ .hf-login-modal-copy.warning { color: #b45309; }
76
+ body.dark .hf-login-modal-copy { color: rgba(229, 231, 235, 0.62); }
77
+ body.dark .hf-login-modal-copy.warning { color: #fbbf24; }
78
+ .hf-login-modal-code { display: block; width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 6px; background: rgba(24, 24, 27, 0.03); border: 1px solid rgba(24, 24, 27, 0.12); color: #18181b; font: 600 20px/1.2 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; letter-spacing: 0; text-align: center; user-select: all; }
79
+ body.dark .hf-login-modal-code { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.1); color: rgba(250, 250, 250, 0.94); }
80
+ .hf-login-modal-note { font-size: 12px; color: inherit !important; }
81
+ @media only screen and (max-width: 480px) {
82
+ .htmlmodal-overlay.minimal { padding: 16px; }
83
+ .htmlmodal-window.minimal { width: calc(100vw - 32px); }
84
+ .htmlmodal-window.minimal .htmlmodal-actions { flex-direction: column-reverse; align-items: stretch; }
85
+ .htmlmodal-window.minimal .htmlmodal-actions .btn { width: 100%; }
86
+ }
41
87
  `
42
88
  document.head.appendChild(style)
43
89
  }
@@ -76,7 +122,8 @@
76
122
  awaiting: null,
77
123
  actions: [],
78
124
  socket: null,
79
- dismissible: true
125
+ dismissible: true,
126
+ variant: null
80
127
  }
81
128
  }
82
129
 
@@ -94,11 +141,15 @@
94
141
  }
95
142
  if (action === 'open') {
96
143
  this.current.id = payload.id
144
+ this.current.dismissible = true
145
+ this.closeBtn.style.display = 'inline-flex'
146
+ this.setVariant(payload.variant)
97
147
  this.show()
98
148
  this.render(payload)
99
149
  } else if (action === 'update') {
100
150
  if (!this.current.id) {
101
151
  this.current.id = payload.id
152
+ this.setVariant(payload.variant)
102
153
  this.show()
103
154
  }
104
155
  this.render(payload)
@@ -121,6 +172,16 @@
121
172
  this.overlay.classList.remove('visible')
122
173
  this.current.id = null
123
174
  this.current.actions = []
175
+ this.current.dismissible = true
176
+ this.closeBtn.style.display = 'inline-flex'
177
+ this.setVariant(null)
178
+ }
179
+
180
+ setVariant(variant) {
181
+ const name = variant === 'minimal' ? 'minimal' : null
182
+ this.current.variant = name
183
+ this.overlay.classList.toggle('minimal', name === 'minimal')
184
+ this.window.classList.toggle('minimal', name === 'minimal')
124
185
  }
125
186
 
126
187
  setTitle(title) {
@@ -190,8 +251,11 @@
190
251
  if (action.icon) {
191
252
  link.innerHTML = `<i class="${action.icon}"></i> ${link.textContent}`
192
253
  }
193
- link.addEventListener('click', () => {
194
- // keep modal open, just open link
254
+ link.addEventListener('click', (event) => {
255
+ if (action.features) {
256
+ event.preventDefault()
257
+ this.handleAction(action)
258
+ }
195
259
  })
196
260
  return link
197
261
  }
@@ -212,14 +276,13 @@
212
276
 
213
277
  buildButtonClass(action, fallback = 'secondary') {
214
278
  const classes = ['btn']
215
- if (action && action.variant) {
279
+ if (action && action.primary) {
280
+ classes.push('primary')
281
+ } else if (action && action.variant) {
216
282
  classes.push(action.variant)
217
283
  } else {
218
284
  classes.push(fallback)
219
285
  }
220
- if (action && action.primary) {
221
- classes.push('primary')
222
- }
223
286
  return classes.join(' ')
224
287
  }
225
288
 
@@ -232,6 +295,9 @@
232
295
  window.open(action.href, target, action.features || 'noopener')
233
296
  return
234
297
  }
298
+ if (action.type === 'submit' && action.href) {
299
+ window.open(action.href, action.target || '_blank', action.features || 'noopener')
300
+ }
235
301
  if (action.type === 'submit' && this.current.awaiting && this.current.socket) {
236
302
  this.emitResponse({ action: action.id || 'submit', payload: action.payload || null })
237
303
  if (action.close !== false) {
@@ -240,7 +306,7 @@
240
306
  } else if (action.type === 'button' && this.current.socket && this.current.awaiting) {
241
307
  this.emitResponse({ action: action.id || 'button', payload: action.payload || null })
242
308
  } else if (action.type === 'button' && action.href) {
243
- window.open(action.href, action.target || '_blank')
309
+ window.open(action.href, action.target || '_blank', action.features || 'noopener')
244
310
  }
245
311
  }
246
312
 
@@ -282,7 +348,10 @@
282
348
  this.actions.style.justifyContent = 'flex-start'
283
349
  }
284
350
  } else {
285
- this.actions.style.justifyContent = 'flex-start'
351
+ this.actions.style.justifyContent = ''
352
+ }
353
+ if (Object.prototype.hasOwnProperty.call(payload, 'variant')) {
354
+ this.setVariant(payload.variant)
286
355
  }
287
356
  if (Object.prototype.hasOwnProperty.call(payload, 'dismissible')) {
288
357
  this.current.dismissible = Boolean(payload.dismissible)
@@ -513,6 +513,34 @@
513
513
  }
514
514
  }
515
515
 
516
+ function ensureSocketReady() {
517
+ if (typeof Socket === 'function') {
518
+ return Promise.resolve(Socket);
519
+ }
520
+ return new Promise((resolve, reject) => {
521
+ const target = document.head || document.body || document.documentElement;
522
+ if (!target) {
523
+ reject(new Error('Socket runtime is unavailable.'));
524
+ return;
525
+ }
526
+
527
+ const script = document.createElement('script');
528
+ script.src = '/Socket.js';
529
+ script.async = true;
530
+ script.onload = () => {
531
+ if (typeof Socket === 'function') {
532
+ resolve(Socket);
533
+ return;
534
+ }
535
+ reject(new Error('Socket runtime failed to load.'));
536
+ };
537
+ script.onerror = () => {
538
+ reject(new Error('Socket runtime failed to load.'));
539
+ };
540
+ target.appendChild(script);
541
+ });
542
+ }
543
+
516
544
  async function checkLauncherInstallDestinationExists(relativePath, folderName) {
517
545
  const response = await fetch('/pinokio/install/exists', {
518
546
  method: 'POST',
@@ -4257,8 +4285,9 @@
4257
4285
  throw new Error('Download is unavailable right now.');
4258
4286
  }
4259
4287
  appendDownloadOutput(ui, `$ ${clone.message}\n\n`);
4288
+ const SocketConstructor = await ensureSocketReady();
4260
4289
  await new Promise((resolve, reject) => {
4261
- const socket = new Socket();
4290
+ const socket = new SocketConstructor();
4262
4291
  let settled = false;
4263
4292
  const settle = (fn, value) => {
4264
4293
  if (settled) {
@@ -0,0 +1,435 @@
1
+ const assert = require('node:assert/strict')
2
+ const test = require('node:test')
3
+
4
+ const HF = require('../kernel/api/hf')
5
+ const Util = require('../kernel/util')
6
+
7
+ const login = {
8
+ verification_uri_complete: 'https://huggingface.co/oauth/device?user_code=ABCD-EFGH',
9
+ user_code: 'ABCD-EFGH',
10
+ expires_in: 300
11
+ }
12
+
13
+ test('hf.login shows a modal, waits for the user to open Hugging Face, then closes after saved keys', async () => {
14
+ const hf = new HF()
15
+ const packets = []
16
+ const actions = []
17
+ const originalOpenURI = Util.openURI
18
+ const originalClipboard = Util.clipboard
19
+ let keyReads = 0
20
+ const loginCalls = []
21
+ const waitCalls = []
22
+ const kernel = {
23
+ connect: {
24
+ async keys(provider) {
25
+ assert.equal(provider, 'huggingface')
26
+ keyReads += 1
27
+ if (keyReads < 3) {
28
+ return null
29
+ }
30
+ return {
31
+ access_token: 'hf_secret',
32
+ token_path: '/tmp/pinokio/hf-token'
33
+ }
34
+ },
35
+ async login(provider, params) {
36
+ loginCalls.push({ provider, params })
37
+ return {
38
+ status: 'pending',
39
+ login,
40
+ token_path: '/tmp/pinokio/hf-token'
41
+ }
42
+ }
43
+ },
44
+ api: {
45
+ async wait(key) {
46
+ waitCalls.push(key)
47
+ return { action: 'open' }
48
+ }
49
+ }
50
+ }
51
+
52
+ Util.clipboard = async (req) => {
53
+ actions.push({ type: 'clipboard', req })
54
+ }
55
+ Util.openURI = async (uri) => {
56
+ actions.push({ type: 'openURI', uri })
57
+ return { ok: true, status: 'stubbed' }
58
+ }
59
+
60
+ try {
61
+ const result = await hf.login({
62
+ parent: { id: 'test-script', path: '/pinokio/api/test/hf-login.js' },
63
+ params: {
64
+ timeout: 100,
65
+ interval: 1
66
+ }
67
+ }, (stream, type) => packets.push({ type, stream }), kernel)
68
+
69
+ assert.deepEqual(loginCalls, [{ provider: 'huggingface', params: { timeout: 100, interval: 1 } }])
70
+ assert.deepEqual(waitCalls, ['/pinokio/api/test/hf-login.js'])
71
+ assert.deepEqual(actions, [{
72
+ type: 'clipboard',
73
+ req: {
74
+ type: 'copy',
75
+ text: 'ABCD-EFGH'
76
+ }
77
+ }])
78
+
79
+ assert.equal(packets.length, 3)
80
+ assert.equal(packets[0].type, 'htmlmodal')
81
+ assert.equal(packets[0].stream.action, 'open')
82
+ assert.equal(packets[0].stream.variant, 'minimal')
83
+ assert.equal(packets[0].stream.actionsAlign, 'end')
84
+ assert.equal(packets[0].stream.await, true)
85
+ assert.equal(packets[0].stream.awaitKey, '/pinokio/api/test/hf-login.js')
86
+ assert.equal(packets[0].stream.dismissible, false)
87
+ assert.match(packets[0].stream.html, /ABCD-EFGH/)
88
+ assert.match(packets[0].stream.html, /copied to your clipboard/)
89
+ assert.deepEqual(packets[0].stream.actions[0], {
90
+ id: 'open',
91
+ label: 'Open Hugging Face',
92
+ type: 'submit',
93
+ href: 'https://huggingface.co/oauth/device?user_code=ABCD-EFGH',
94
+ target: '_blank',
95
+ features: 'browser',
96
+ primary: true,
97
+ close: false,
98
+ icon: 'fa-solid fa-arrow-up-right-from-square'
99
+ })
100
+
101
+ assert.equal(packets[1].type, 'htmlmodal')
102
+ assert.equal(packets[1].stream.action, 'update')
103
+ assert.equal(packets[1].stream.variant, 'minimal')
104
+ assert.equal(packets[1].stream.actionsAlign, 'end')
105
+ assert.equal(packets[1].stream.status.waiting, true)
106
+ assert.deepEqual(packets[1].stream.actions[0], {
107
+ id: 'open-again',
108
+ label: 'Open Again',
109
+ type: 'link',
110
+ href: 'https://huggingface.co/oauth/device?user_code=ABCD-EFGH',
111
+ target: '_blank',
112
+ features: 'browser',
113
+ variant: 'secondary',
114
+ icon: 'fa-solid fa-arrow-up-right-from-square'
115
+ })
116
+
117
+ assert.equal(packets[2].type, 'htmlmodal')
118
+ assert.equal(packets[2].stream.action, 'close')
119
+
120
+ assert.deepEqual(result, {
121
+ status: 'success',
122
+ login,
123
+ token_path: '/tmp/pinokio/hf-token',
124
+ clipboard: { ok: true },
125
+ open: { action: 'open' }
126
+ })
127
+ } finally {
128
+ Util.openURI = originalOpenURI
129
+ Util.clipboard = originalClipboard
130
+ }
131
+ })
132
+
133
+ test('hf.login returns success without opening anything when already logged in', async () => {
134
+ const hf = new HF()
135
+ const opened = []
136
+ const copied = []
137
+ const originalOpenURI = Util.openURI
138
+ const originalClipboard = Util.clipboard
139
+ const kernel = {
140
+ connect: {
141
+ async keys(provider) {
142
+ assert.equal(provider, 'huggingface')
143
+ return {
144
+ access_token: 'hf_secret',
145
+ token_path: '/tmp/pinokio/hf-token'
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ Util.openURI = async (uri) => {
152
+ opened.push(uri)
153
+ return { ok: true, status: 'stubbed' }
154
+ }
155
+ Util.clipboard = async (req) => {
156
+ copied.push(req)
157
+ }
158
+
159
+ try {
160
+ const result = await hf.login({ params: {} }, () => {}, kernel)
161
+
162
+ assert.deepEqual(opened, [])
163
+ assert.deepEqual(copied, [])
164
+ assert.deepEqual(result, {
165
+ status: 'success',
166
+ already_logged_in: true,
167
+ token_path: '/tmp/pinokio/hf-token'
168
+ })
169
+ } finally {
170
+ Util.openURI = originalOpenURI
171
+ Util.clipboard = originalClipboard
172
+ }
173
+ })
174
+
175
+ test('hf.login can use the non-modal browser fallback when modal is disabled', async () => {
176
+ const hf = new HF()
177
+ const opened = []
178
+ const copied = []
179
+ const output = []
180
+ const originalOpenURI = Util.openURI
181
+ const originalClipboard = Util.clipboard
182
+ const kernel = {
183
+ connect: {
184
+ async keys(provider) {
185
+ assert.equal(provider, 'huggingface')
186
+ return null
187
+ },
188
+ async login(provider, params) {
189
+ assert.equal(provider, 'huggingface')
190
+ assert.deepEqual(params, { modal: false, wait: false })
191
+ return {
192
+ status: 'pending',
193
+ login,
194
+ token_path: '/tmp/pinokio/hf-token'
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ Util.clipboard = async (req) => {
201
+ copied.push(req)
202
+ }
203
+ Util.openURI = async (uri) => {
204
+ opened.push(uri)
205
+ return { ok: true, status: 'stubbed' }
206
+ }
207
+
208
+ try {
209
+ const result = await hf.login({ params: { modal: false, wait: false } }, (chunk) => output.push(chunk), kernel)
210
+
211
+ assert.deepEqual(copied, [{
212
+ type: 'copy',
213
+ text: 'ABCD-EFGH'
214
+ }])
215
+ assert.deepEqual(opened, ['https://huggingface.co/oauth/device?user_code=ABCD-EFGH'])
216
+ assert.deepEqual(output, [
217
+ { raw: '\r\nHugging Face code copied to clipboard: ABCD-EFGH\r\n' },
218
+ { raw: '\r\nHugging Face login modal disabled.\r\n' },
219
+ { raw: 'Opening Hugging Face login: https://huggingface.co/oauth/device?user_code=ABCD-EFGH\r\nThe code is already on your clipboard; paste it if Hugging Face asks.\r\n' }
220
+ ])
221
+ assert.deepEqual(result, {
222
+ status: 'pending',
223
+ login,
224
+ token_path: '/tmp/pinokio/hf-token',
225
+ clipboard: { ok: true },
226
+ open: { ok: true, status: 'stubbed' }
227
+ })
228
+ } finally {
229
+ Util.openURI = originalOpenURI
230
+ Util.clipboard = originalClipboard
231
+ }
232
+ })
233
+
234
+ test('hf.login modal displays clipboard failure without opening the browser automatically', async () => {
235
+ const hf = new HF()
236
+ const packets = []
237
+ const opened = []
238
+ const originalOpenURI = Util.openURI
239
+ const originalClipboard = Util.clipboard
240
+ const kernel = {
241
+ connect: {
242
+ async keys(provider) {
243
+ assert.equal(provider, 'huggingface')
244
+ return null
245
+ },
246
+ async login(provider) {
247
+ assert.equal(provider, 'huggingface')
248
+ return {
249
+ status: 'pending',
250
+ login,
251
+ token_path: '/tmp/pinokio/hf-token'
252
+ }
253
+ }
254
+ },
255
+ api: {
256
+ async wait() {
257
+ return { action: 'open' }
258
+ }
259
+ }
260
+ }
261
+
262
+ Util.clipboard = async () => {
263
+ throw new Error('clipboard denied')
264
+ }
265
+ Util.openURI = async (uri) => {
266
+ opened.push(uri)
267
+ return { ok: true, status: 'stubbed' }
268
+ }
269
+
270
+ try {
271
+ const result = await hf.login({
272
+ parent: { id: 'test-script' },
273
+ params: { wait: false }
274
+ }, (stream, type) => packets.push({ type, stream }), kernel)
275
+
276
+ assert.deepEqual(opened, [])
277
+ assert.equal(packets.length, 3)
278
+ assert.match(packets[0].stream.html, /Clipboard copy failed/)
279
+ assert.match(packets[0].stream.html, /ABCD-EFGH/)
280
+ assert.equal(packets[1].stream.action, 'update')
281
+ assert.equal(packets[2].stream.action, 'close')
282
+ assert.deepEqual(result, {
283
+ status: 'pending',
284
+ login,
285
+ token_path: '/tmp/pinokio/hf-token',
286
+ clipboard: { ok: false, error: 'clipboard denied' },
287
+ open: { action: 'open' }
288
+ })
289
+ } finally {
290
+ Util.openURI = originalOpenURI
291
+ Util.clipboard = originalClipboard
292
+ }
293
+ })
294
+
295
+ test('hf.login cancels the pending provider login when the modal is canceled', async () => {
296
+ const hf = new HF()
297
+ const packets = []
298
+ const cancelCalls = []
299
+ const originalClipboard = Util.clipboard
300
+ const kernel = {
301
+ connect: {
302
+ async keys(provider) {
303
+ assert.equal(provider, 'huggingface')
304
+ return null
305
+ },
306
+ async login(provider) {
307
+ assert.equal(provider, 'huggingface')
308
+ return {
309
+ status: 'pending',
310
+ login,
311
+ token_path: '/tmp/pinokio/hf-token'
312
+ }
313
+ },
314
+ async cancelLogin(provider, params) {
315
+ cancelCalls.push({ provider, params })
316
+ }
317
+ },
318
+ api: {
319
+ async wait() {
320
+ return { action: 'cancel' }
321
+ }
322
+ }
323
+ }
324
+
325
+ Util.clipboard = async () => {}
326
+
327
+ try {
328
+ await assert.rejects(
329
+ () => hf.login({
330
+ parent: { id: 'test-script' },
331
+ params: { force: true }
332
+ }, (stream, type) => packets.push({ type, stream }), kernel),
333
+ /Hugging Face login canceled/
334
+ )
335
+
336
+ assert.deepEqual(cancelCalls, [{
337
+ provider: 'huggingface',
338
+ params: { force: true }
339
+ }])
340
+ assert.equal(packets.at(-1).stream.action, 'close')
341
+ } finally {
342
+ Util.clipboard = originalClipboard
343
+ }
344
+ })
345
+
346
+ test('hf.login cancels the pending provider login after timeout', async () => {
347
+ const hf = new HF()
348
+ const cancelCalls = []
349
+ const originalClipboard = Util.clipboard
350
+ const kernel = {
351
+ connect: {
352
+ async keys(provider) {
353
+ assert.equal(provider, 'huggingface')
354
+ return null
355
+ },
356
+ async login(provider) {
357
+ assert.equal(provider, 'huggingface')
358
+ return {
359
+ status: 'pending',
360
+ login,
361
+ token_path: '/tmp/pinokio/hf-token'
362
+ }
363
+ },
364
+ async cancelLogin(provider, params) {
365
+ cancelCalls.push({ provider, params })
366
+ }
367
+ },
368
+ api: {
369
+ async wait() {
370
+ return { action: 'open' }
371
+ }
372
+ }
373
+ }
374
+
375
+ Util.clipboard = async () => {}
376
+
377
+ try {
378
+ const result = await hf.login({
379
+ parent: { id: 'test-script' },
380
+ params: { timeout: 1, interval: 1 }
381
+ }, () => {}, kernel)
382
+
383
+ assert.equal(result.status, 'timeout')
384
+ assert.deepEqual(cancelCalls, [{
385
+ provider: 'huggingface',
386
+ params: { timeout: 1, interval: 1 }
387
+ }])
388
+ } finally {
389
+ Util.clipboard = originalClipboard
390
+ }
391
+ })
392
+
393
+ test('hf.logout delegates to the Hugging Face connect provider and returns success', async () => {
394
+ const hf = new HF()
395
+ const req = { params: {} }
396
+ const calls = []
397
+ const kernel = {
398
+ connect: {
399
+ async logout(provider, params) {
400
+ calls.push({ method: 'logout', provider, params })
401
+ }
402
+ }
403
+ }
404
+
405
+ const result = await hf.logout(req, () => {}, kernel)
406
+
407
+ assert.deepEqual(result, { status: 'success' })
408
+ assert.deepEqual(calls, [{ method: 'logout', provider: 'huggingface', params: req.params }])
409
+ })
410
+
411
+ test('hf.login reports unavailable Hugging Face connect provider', async () => {
412
+ const hf = new HF()
413
+
414
+ await assert.rejects(
415
+ () => hf.login({ params: {} }, () => {}, {}),
416
+ /Hugging Face connect keys is not available/
417
+ )
418
+ })
419
+
420
+ test('hf.login reports unavailable Hugging Face login starter after checking existing keys', async () => {
421
+ const hf = new HF()
422
+ const kernel = {
423
+ connect: {
424
+ async keys(provider) {
425
+ assert.equal(provider, 'huggingface')
426
+ return null
427
+ }
428
+ }
429
+ }
430
+
431
+ await assert.rejects(
432
+ () => hf.login({ params: {} }, () => {}, kernel),
433
+ /Hugging Face connect login is not available/
434
+ )
435
+ })
@@ -71,3 +71,36 @@ test('Hugging Face connect uses shared HF_TOKEN_PATH and ignores ambient HF_TOKE
71
71
  await fs.rm(root, { recursive: true, force: true })
72
72
  }
73
73
  })
74
+
75
+ test('Hugging Face connect cancelLogin only stops a pending login session', async () => {
76
+ const provider = new HuggingfaceConnect(createKernel('/tmp/pinokio'), {})
77
+ let killed = false
78
+ provider.loginSession = {
79
+ status: 'pending',
80
+ error: null,
81
+ child: {
82
+ kill() {
83
+ killed = true
84
+ }
85
+ }
86
+ }
87
+
88
+ await provider.cancelLogin()
89
+
90
+ assert.equal(killed, true)
91
+ assert.equal(provider.loginSession, null)
92
+
93
+ const completeSession = {
94
+ status: 'success',
95
+ child: {
96
+ kill() {
97
+ throw new Error('should not kill completed sessions')
98
+ }
99
+ }
100
+ }
101
+ provider.loginSession = completeSession
102
+
103
+ await provider.cancelLogin()
104
+
105
+ assert.equal(provider.loginSession, completeSession)
106
+ })