vanspod 0.0.0-stage → 0.1.2
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/README.md +74 -2
- package/bin/register.cjs +11 -0
- package/bin/vanspod.cjs +2 -0
- package/dist/adapterSettings.js +37 -0
- package/dist/adapters/chat-output.js +69 -0
- package/dist/adapters/chat-stream.js +44 -0
- package/dist/adapters/chat.js +63 -0
- package/dist/adapters/claude-reasoning.js +118 -0
- package/dist/adapters/claude-request.js +190 -0
- package/dist/adapters/claude-response.js +79 -0
- package/dist/adapters/index.js +19 -0
- package/dist/adapters/messages-stream.js +126 -0
- package/dist/adapters/messages.js +21 -0
- package/dist/adapters/responses-stream.js +70 -0
- package/dist/adapters/responses.js +28 -0
- package/dist/adapters/stream.js +99 -0
- package/dist/adapters/transport.js +26 -0
- package/dist/adapters/types.js +35 -0
- package/dist/appearance.js +10 -0
- package/dist/classroomApp.js +1814 -0
- package/dist/classroomUpdate.js +155 -0
- package/dist/claudeWebSearch.js +248 -0
- package/dist/clientDetection.js +35 -0
- package/dist/clientIcons.js +33 -0
- package/dist/clientRouteRuntime.js +149 -0
- package/dist/clientRoutes.js +118 -0
- package/dist/clientSettingsBackup.js +110 -0
- package/dist/clientTraffic.js +26 -0
- package/dist/clipboard.js +32 -0
- package/dist/codexWebSearch.js +80 -0
- package/dist/codexWire/apply-patch-envelope.js +139 -0
- package/dist/codexWire/chat-options.js +51 -0
- package/dist/codexWire/chat-tools.js +80 -0
- package/dist/codexWire/freeform-wrapper-scan.js +326 -0
- package/dist/codexWire/progressive-freeform-input.js +133 -0
- package/dist/codexWire/request.js +73 -0
- package/dist/codexWire/responses-tools.js +88 -0
- package/dist/codexWire/tool-arguments.js +39 -0
- package/dist/codexWire/tool-guidance.js +77 -0
- package/dist/commandRunner.js +100 -0
- package/dist/connectionMascot.js +235 -0
- package/dist/courseCatalog.js +131 -0
- package/dist/desktop.js +25 -0
- package/dist/entry.js +17 -0
- package/dist/environmentIcons.js +11 -0
- package/dist/exitGuard.js +17 -0
- package/dist/folderPicker.js +35 -0
- package/dist/installProgress.js +171 -0
- package/dist/installations.js +72 -0
- package/dist/instance.js +160 -0
- package/dist/launcher.js +155 -0
- package/dist/legacyMigration.js +87 -0
- package/dist/lifecyclePage.js +41 -0
- package/dist/modelProxy.js +105 -0
- package/dist/modelStatus.js +42 -0
- package/dist/page.js +1016 -0
- package/dist/pagePrototype.js +528 -0
- package/dist/platformStorage.js +91 -0
- package/dist/removal.js +41 -0
- package/dist/responsesChat.js +835 -0
- package/dist/routeFiles.js +646 -0
- package/dist/routerSessionModels.js +32 -0
- package/dist/runtimeOptions.js +14 -0
- package/dist/secureStorage.js +63 -0
- package/dist/settingsActions.js +100 -0
- package/dist/start.js +822 -0
- package/dist/storageErrors.js +17 -0
- package/dist/storageLocation.js +58 -0
- package/dist/version.js +4 -0
- package/dist/vsCodeRouteSettings.js +208 -0
- package/dist/webSearch.js +56 -0
- package/dist/windowsSecureStorage.js +163 -0
- package/media/brand-icon.ico +0 -0
- package/media/brand-logo.png +0 -0
- package/media/fadou-superhero-blink.png +0 -0
- package/media/fadou-superhero.png +0 -0
- package/package.json +27 -4
- package/scripts/tray.ps1 +40 -0
- package/scripts/uninstall.ps1 +133 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StorageError = void 0;
|
|
4
|
+
class StorageError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code) {
|
|
7
|
+
super(code === "invalid" ? "課堂連線資料格式不正確。請重新連線。" :
|
|
8
|
+
code === "legacy" ? "課堂連線需要安全遷移。" :
|
|
9
|
+
code === "migration-failed" ? "舊課堂連線無法安全遷移,已移除舊憑證。請重新連線。" :
|
|
10
|
+
code === "legacy-cleanup-failed" ? "無法移除舊課堂憑證。請手動刪除 VansPod 的 connection.json,再重新連線。" :
|
|
11
|
+
code === "clear-failed" ? "無法清除已保存的課堂連線。" :
|
|
12
|
+
code === "unreadable" ? "課堂連線無法解密。請重新連線。" : "安全儲存暫時無法使用。");
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "StorageError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.StorageError = StorageError;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.relocateConnectionStorage = relocateConnectionStorage;
|
|
7
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const storageErrors_1 = require("./storageErrors");
|
|
11
|
+
/** Move the existing envelope unchanged; the secure adapter handles decryption and plaintext migration. */
|
|
12
|
+
function relocateConnectionStorage(storage, target, legacy) {
|
|
13
|
+
let initialized;
|
|
14
|
+
async function removeLegacy() {
|
|
15
|
+
try {
|
|
16
|
+
await promises_1.default.unlink(legacy);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code !== "ENOENT")
|
|
20
|
+
throw new storageErrors_1.StorageError("clear-failed");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function relocate() {
|
|
24
|
+
try {
|
|
25
|
+
await promises_1.default.access(legacy);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code === "ENOENT")
|
|
29
|
+
return;
|
|
30
|
+
throw new storageErrors_1.StorageError("unavailable");
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(target), { recursive: true });
|
|
34
|
+
// Never overwrite a newer connection. Exclusive copying also supports different drives.
|
|
35
|
+
await promises_1.default.copyFile(legacy, target, node_fs_1.constants.COPYFILE_EXCL);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== "EEXIST")
|
|
39
|
+
throw new storageErrors_1.StorageError("unavailable");
|
|
40
|
+
}
|
|
41
|
+
await removeLegacy();
|
|
42
|
+
}
|
|
43
|
+
function initialize() {
|
|
44
|
+
if (!initialized)
|
|
45
|
+
initialized = relocate();
|
|
46
|
+
return initialized;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
async getConnection() { await initialize(); return storage.getConnection(); },
|
|
50
|
+
async setConnection(connection, options) { await initialize(); return storage.setConnection(connection, options); },
|
|
51
|
+
async clearConnection() {
|
|
52
|
+
await initialize().catch(() => { });
|
|
53
|
+
await removeLegacy();
|
|
54
|
+
await storage.clearConnection();
|
|
55
|
+
initialized = Promise.resolve();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createVsCodeRouteSettings = createVsCodeRouteSettings;
|
|
7
|
+
const node_util_1 = require("node:util");
|
|
8
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
function isJournal(value) {
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
12
|
+
return false;
|
|
13
|
+
const item = value;
|
|
14
|
+
return item.version === 1 && item.route === "vsCodeChat" && !!item.intended && typeof item.intended === "object" &&
|
|
15
|
+
!!item.owned && item.owned.name === "VCRouter" && item.owned.vendor === "customendpoint";
|
|
16
|
+
}
|
|
17
|
+
function asProviderArray(value) {
|
|
18
|
+
if (!Array.isArray(value) || value.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
|
|
19
|
+
throw new Error("VS Code Chat 模型設定格式無效。");
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
async function readProviders(filename) {
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = await promises_1.default.readFile(filename, "utf8");
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code === "ENOENT")
|
|
30
|
+
return [];
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return asProviderArray(JSON.parse(raw));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof SyntaxError)
|
|
38
|
+
throw new Error("VS Code Chat 模型設定不是合法 JSON。");
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function atomicJson(filename, value) {
|
|
43
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(filename), { recursive: true });
|
|
44
|
+
const temporary = `${filename}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
await promises_1.default.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
47
|
+
await promises_1.default.rename(temporary, filename);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await promises_1.default.rm(temporary, { force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function readJournal(filename) {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = await promises_1.default.readFile(filename, "utf8");
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === "ENOENT")
|
|
60
|
+
return undefined;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(raw);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new Error("VS Code Chat 設定 journal 已損毀。");
|
|
69
|
+
}
|
|
70
|
+
if (!isJournal(parsed))
|
|
71
|
+
throw new Error("VS Code Chat 設定 journal 已損毀。");
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
function namedIndexes(providers) {
|
|
75
|
+
const result = [];
|
|
76
|
+
providers.forEach((provider, index) => {
|
|
77
|
+
if (provider.name === "VCRouter")
|
|
78
|
+
result.push(index);
|
|
79
|
+
});
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
function containsOldExtensionSecret(provider) {
|
|
83
|
+
return JSON.stringify(provider).includes("${input:chat.lm.secret.");
|
|
84
|
+
}
|
|
85
|
+
function classroomTemplate(providers) {
|
|
86
|
+
const found = providers.find(provider => provider.name === "VCRouter" && provider.vendor === "customendpoint");
|
|
87
|
+
if (!found)
|
|
88
|
+
throw new Error("Router 沒有提供 VS Code Chat 模型設定。");
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
const CREDENTIAL_KEYS = new Set([
|
|
92
|
+
"apikey",
|
|
93
|
+
"xapikey",
|
|
94
|
+
"authorization",
|
|
95
|
+
"proxyauthorization",
|
|
96
|
+
"token",
|
|
97
|
+
"accesstoken",
|
|
98
|
+
"credential",
|
|
99
|
+
"credentials",
|
|
100
|
+
"password",
|
|
101
|
+
"secret",
|
|
102
|
+
"secretref",
|
|
103
|
+
]);
|
|
104
|
+
function withoutCredentials(value) {
|
|
105
|
+
if (typeof value === "string" && value.includes("${input:chat.lm.secret."))
|
|
106
|
+
return undefined;
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
return value.map(withoutCredentials).filter(item => item !== undefined);
|
|
109
|
+
}
|
|
110
|
+
if (!value || typeof value !== "object")
|
|
111
|
+
return value;
|
|
112
|
+
const clean = {};
|
|
113
|
+
for (const [key, item] of Object.entries(value)) {
|
|
114
|
+
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
115
|
+
if (CREDENTIAL_KEYS.has(normalized))
|
|
116
|
+
continue;
|
|
117
|
+
const sanitized = withoutCredentials(item);
|
|
118
|
+
if (sanitized !== undefined)
|
|
119
|
+
clean[key] = sanitized;
|
|
120
|
+
}
|
|
121
|
+
return clean;
|
|
122
|
+
}
|
|
123
|
+
function intendedProvider(template, adapter, origin) {
|
|
124
|
+
const provider = withoutCredentials(structuredClone(template));
|
|
125
|
+
provider.name = "VCRouter";
|
|
126
|
+
provider.vendor = "customendpoint";
|
|
127
|
+
provider.apiType = adapter;
|
|
128
|
+
provider.models = (Array.isArray(provider.models) ? provider.models : []).map(value => {
|
|
129
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
130
|
+
return value;
|
|
131
|
+
const model = structuredClone(value);
|
|
132
|
+
model.url = `${origin.replace(/\/$/, "")}/v1`;
|
|
133
|
+
model.apiType = adapter;
|
|
134
|
+
return model;
|
|
135
|
+
});
|
|
136
|
+
return provider;
|
|
137
|
+
}
|
|
138
|
+
function createVsCodeRouteSettings(options) {
|
|
139
|
+
const restore = async () => {
|
|
140
|
+
const journal = await readJournal(options.journalFile);
|
|
141
|
+
if (!journal)
|
|
142
|
+
return { changed: false };
|
|
143
|
+
const current = await readProviders(options.settingsFile);
|
|
144
|
+
const indexes = namedIndexes(current);
|
|
145
|
+
if (indexes.length !== 1 || !(0, node_util_1.isDeepStrictEqual)(current[indexes[0]], journal.intended)) {
|
|
146
|
+
throw new Error("VS Code 的 VCRouter provider 已被其他程式修改,VansPod 不會覆寫。");
|
|
147
|
+
}
|
|
148
|
+
const next = current.filter((_provider, index) => index !== indexes[0]);
|
|
149
|
+
await atomicJson(options.settingsFile, next);
|
|
150
|
+
await promises_1.default.rm(options.journalFile, { force: true });
|
|
151
|
+
return { changed: true };
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
async publish({ providers, adapter, origin }) {
|
|
155
|
+
const current = await readProviders(options.settingsFile);
|
|
156
|
+
const intended = intendedProvider(classroomTemplate(providers), adapter, origin);
|
|
157
|
+
const journal = await readJournal(options.journalFile);
|
|
158
|
+
let original = null;
|
|
159
|
+
let next;
|
|
160
|
+
if (journal) {
|
|
161
|
+
const indexes = namedIndexes(current);
|
|
162
|
+
if (indexes.length !== 1 || !(0, node_util_1.isDeepStrictEqual)(current[indexes[0]], journal.intended)) {
|
|
163
|
+
throw new Error("VS Code 的 VCRouter provider 已被其他程式修改,VansPod 不會覆寫。");
|
|
164
|
+
}
|
|
165
|
+
original = journal.original;
|
|
166
|
+
next = current.map((provider, index) => index === indexes[0] ? intended : provider);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const existing = namedIndexes(current).map(index => current[index]);
|
|
170
|
+
if (existing.some(containsOldExtensionSecret)) {
|
|
171
|
+
throw new Error("偵測到舊擴充的 VCRouter provider。請先在舊擴充清除舊擴充的 Classroom Connection,再回到 VansPod 重新載入。");
|
|
172
|
+
}
|
|
173
|
+
if (existing.length > 0) {
|
|
174
|
+
throw new Error("既有 VCRouter provider 的 ownership 無法確認,VansPod 不會覆寫。");
|
|
175
|
+
}
|
|
176
|
+
next = [...current, intended];
|
|
177
|
+
}
|
|
178
|
+
if ((0, node_util_1.isDeepStrictEqual)(next, current))
|
|
179
|
+
return { changed: false };
|
|
180
|
+
const nextJournal = {
|
|
181
|
+
version: 1,
|
|
182
|
+
route: "vsCodeChat",
|
|
183
|
+
original,
|
|
184
|
+
intended,
|
|
185
|
+
owned: { name: "VCRouter", vendor: "customendpoint" },
|
|
186
|
+
};
|
|
187
|
+
await atomicJson(options.journalFile, nextJournal);
|
|
188
|
+
await atomicJson(options.settingsFile, next);
|
|
189
|
+
return { changed: true };
|
|
190
|
+
},
|
|
191
|
+
restore,
|
|
192
|
+
async initializeNative() {
|
|
193
|
+
if (await readJournal(options.journalFile))
|
|
194
|
+
return restore();
|
|
195
|
+
const current = await readProviders(options.settingsFile);
|
|
196
|
+
const origins = new Set((options.legacyOrigins ?? ["http://127.0.0.1:47821"]).map(origin => origin.replace(/\/$/, "")));
|
|
197
|
+
const next = current.filter(provider => {
|
|
198
|
+
const keys = Object.keys(provider).sort();
|
|
199
|
+
return !(keys.length === 2 && keys[0] === "name" && keys[1] === "url" && provider.name === "VCRouter" &&
|
|
200
|
+
typeof provider.url === "string" && origins.has(provider.url.replace(/\/$/, "")));
|
|
201
|
+
});
|
|
202
|
+
if ((0, node_util_1.isDeepStrictEqual)(next, current))
|
|
203
|
+
return { changed: false };
|
|
204
|
+
await atomicJson(options.settingsFile, next);
|
|
205
|
+
return { changed: true };
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NO_SHELL = exports.MAX_QUERY_LENGTH = void 0;
|
|
4
|
+
exports.explainSearchFailure = explainSearchFailure;
|
|
5
|
+
exports.webSearch = webSearch;
|
|
6
|
+
const types_1 = require("./adapters/types");
|
|
7
|
+
exports.MAX_QUERY_LENGTH = 1000;
|
|
8
|
+
// Every explanation below is read verbatim by the model, hence the English half. None
|
|
9
|
+
// of them may hint at another route to the information: an earlier refusal did, and the
|
|
10
|
+
// model crawled sites from the shell for minutes (ADR-0007). What is ruled out is
|
|
11
|
+
// searching by other means; reading one known URL is not Web Search, and only the reply
|
|
12
|
+
// to Codex's own open, click or find says so (codexWebSearch.ts).
|
|
13
|
+
exports.NO_SHELL = "Do not use shell commands (curl, wget, scripts) to search the web or crawl sites for this information.";
|
|
14
|
+
const ROUTER_REFUSALS = {
|
|
15
|
+
web_search_disabled: `這堂課沒有開放網路搜尋。Web search is not enabled for this class session. This is a class setting, not a malfunction, and no search was performed. Do not retry this tool. ${exports.NO_SHELL} Tell the user directly that web search is not enabled for this class session.`,
|
|
16
|
+
web_search_rate_limited: `搜尋太頻繁,請稍後再試。Web search is temporarily rate limited: there were too many searches in the last minute, so no search was performed. Do not retry this tool now. ${exports.NO_SHELL} Tell the user to wait about a minute and then ask again.`,
|
|
17
|
+
web_search_unavailable: `網路搜尋服務目前無法使用。The classroom web search service is not available right now. This is a service problem, not a class setting, and no search was performed. Do not retry this tool. ${exports.NO_SHELL} Tell the user that web search is currently out of service and that they can report it to their teacher.`,
|
|
18
|
+
};
|
|
19
|
+
const SEARCH_FAILED = `搜尋失敗,沒有取得結果。The web search failed: the search service returned an error, so there are no results. This is not a class setting. You may retry this tool once; if it fails again, tell the user that the search failed and that they can try again later. ${exports.NO_SHELL}`;
|
|
20
|
+
/** What the model is told when a search failed with this Router error code. */
|
|
21
|
+
function explainSearchFailure(errorCode) {
|
|
22
|
+
return ROUTER_REFUSALS[errorCode] ?? SEARCH_FAILED;
|
|
23
|
+
}
|
|
24
|
+
function textOf(value) { return typeof value === "string" ? value : ""; }
|
|
25
|
+
// Router relays its search provider's HTTP status (a provider 429 arrives as 429 with
|
|
26
|
+
// code null), so the error code alone tells a refusal from an upstream failure.
|
|
27
|
+
async function routerErrorCode(response) {
|
|
28
|
+
try {
|
|
29
|
+
return textOf((0, types_1.object)(await response.json(), "search error").error?.code);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Only the query strings and the Classroom API Key leave. An unreachable Router or a
|
|
36
|
+
// reply that is not a result list is a failure like any other; an abort is the caller's.
|
|
37
|
+
async function webSearch(queries, routerBaseUrl, classroomApiKey, signal) {
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch(`${routerBaseUrl.replace(/\/+$/, "")}/v1/search`, {
|
|
40
|
+
method: "POST", signal,
|
|
41
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${classroomApiKey}` },
|
|
42
|
+
body: JSON.stringify({ queries }),
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
return { errorCode: await routerErrorCode(response) };
|
|
46
|
+
return { results: (0, types_1.list)((0, types_1.object)(await response.json(), "search response").results, "results").map(item => {
|
|
47
|
+
const result = (0, types_1.object)(item, "search result");
|
|
48
|
+
return { query: textOf(result.query), title: textOf(result.title), url: textOf(result.url), text: textOf(result.text) };
|
|
49
|
+
}) };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (signal?.aborted)
|
|
53
|
+
throw error;
|
|
54
|
+
return { errorCode: "" };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.protectedFiles = exports.StorageError = void 0;
|
|
7
|
+
exports.createWindowsDataProtector = createWindowsDataProtector;
|
|
8
|
+
exports.createWindowsSecureStorage = createWindowsSecureStorage;
|
|
9
|
+
const node_child_process_1 = require("node:child_process");
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
const storageErrors_1 = require("./storageErrors");
|
|
14
|
+
var storageErrors_2 = require("./storageErrors");
|
|
15
|
+
Object.defineProperty(exports, "StorageError", { enumerable: true, get: function () { return storageErrors_2.StorageError; } });
|
|
16
|
+
function createWindowsDataProtector(options = {}) {
|
|
17
|
+
const launch = options.launch ?? node_child_process_1.spawn;
|
|
18
|
+
const maxBytes = options.maxBytes ?? 2_097_152;
|
|
19
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
20
|
+
const executable = node_path_1.default.win32.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
21
|
+
function run(operation, input) {
|
|
22
|
+
if (Buffer.byteLength(input, "utf8") > maxBytes)
|
|
23
|
+
return Promise.reject(new storageErrors_1.StorageError("unavailable"));
|
|
24
|
+
const transform = operation === "Protect"
|
|
25
|
+
? "$bytes = [Text.Encoding]::UTF8.GetBytes($payload); $out = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser); [Console]::Out.Write([Convert]::ToBase64String($out))"
|
|
26
|
+
: "$bytes = [Convert]::FromBase64String($payload); $out = [Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser); [Console]::Out.Write([Text.Encoding]::UTF8.GetString($out))";
|
|
27
|
+
const command = "$ErrorActionPreference = 'Stop'; [Console]::InputEncoding = New-Object Text.UTF8Encoding($false); [Console]::OutputEncoding = New-Object Text.UTF8Encoding($false); try { Add-Type -AssemblyName System.Security; $payload = [Console]::In.ReadToEnd(); " + transform + " } catch { exit 1 }";
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
let child;
|
|
30
|
+
try {
|
|
31
|
+
child = launch(executable, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
reject(new storageErrors_1.StorageError("unavailable"));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
let finished = false;
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
const output = [];
|
|
40
|
+
const finish = (failed) => {
|
|
41
|
+
if (finished)
|
|
42
|
+
return;
|
|
43
|
+
finished = true;
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
if (failed) {
|
|
46
|
+
child.kill();
|
|
47
|
+
reject(new storageErrors_1.StorageError("unavailable"));
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
resolve(Buffer.concat(output).toString("utf8"));
|
|
51
|
+
};
|
|
52
|
+
const timer = setTimeout(() => finish(true), timeoutMs);
|
|
53
|
+
child.on("error", () => finish(true));
|
|
54
|
+
child.stdin?.on("error", () => finish(true));
|
|
55
|
+
child.stdout?.on("data", (chunk) => {
|
|
56
|
+
bytes += chunk.length;
|
|
57
|
+
if (bytes > maxBytes) {
|
|
58
|
+
finish(true);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
output.push(chunk);
|
|
62
|
+
});
|
|
63
|
+
child.stderr?.on("data", (chunk) => {
|
|
64
|
+
// Never forward raw PowerShell diagnostics; they may contain sensitive input.
|
|
65
|
+
bytes += chunk.length;
|
|
66
|
+
if (bytes > maxBytes)
|
|
67
|
+
finish(true);
|
|
68
|
+
});
|
|
69
|
+
child.on("close", code => finish(code !== 0));
|
|
70
|
+
child.stdin?.end(input, "utf8");
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return { protect: value => run("Protect", value), unprotect: value => run("Unprotect", value) };
|
|
74
|
+
}
|
|
75
|
+
exports.protectedFiles = {
|
|
76
|
+
read: file => promises_1.default.readFile(file, "utf8"),
|
|
77
|
+
async write(file, contents) {
|
|
78
|
+
const handle = await promises_1.default.open(file, "wx", 0o600);
|
|
79
|
+
try {
|
|
80
|
+
await handle.writeFile(contents, "utf8");
|
|
81
|
+
await handle.sync();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await handle.close();
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
replace: (source, target) => promises_1.default.rename(source, target),
|
|
88
|
+
remove: file => promises_1.default.unlink(file),
|
|
89
|
+
async mkdir(directory) { await promises_1.default.mkdir(directory, { recursive: true }); },
|
|
90
|
+
};
|
|
91
|
+
function absent(error) { return error?.code === "ENOENT"; }
|
|
92
|
+
function createWindowsSecureStorage(filePath, options = {}) {
|
|
93
|
+
const protector = options.protector ?? createWindowsDataProtector();
|
|
94
|
+
const files = options.files ?? exports.protectedFiles;
|
|
95
|
+
const fileFor = (key) => key === "classroom-connection" ? filePath :
|
|
96
|
+
node_path_1.default.join(node_path_1.default.dirname(filePath), `secure-${(0, node_crypto_1.createHash)("sha256").update(key).digest("hex")}.json`);
|
|
97
|
+
return {
|
|
98
|
+
async get(key) {
|
|
99
|
+
let raw;
|
|
100
|
+
try {
|
|
101
|
+
raw = await files.read(fileFor(key));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (absent(error))
|
|
105
|
+
return undefined;
|
|
106
|
+
throw new storageErrors_1.StorageError("unavailable");
|
|
107
|
+
}
|
|
108
|
+
let value;
|
|
109
|
+
try {
|
|
110
|
+
value = JSON.parse(raw);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
throw new storageErrors_1.StorageError("unreadable");
|
|
114
|
+
}
|
|
115
|
+
if (!value || typeof value !== "object")
|
|
116
|
+
throw new storageErrors_1.StorageError("unreadable");
|
|
117
|
+
const envelope = value;
|
|
118
|
+
if (typeof envelope.apiKey === "string" && envelope.apiKey && envelope.secureStorage === undefined)
|
|
119
|
+
throw new storageErrors_1.StorageError("legacy");
|
|
120
|
+
if (envelope.secureStorage !== 1 || envelope.scheme !== "dpapi-current-user" ||
|
|
121
|
+
typeof envelope.ciphertext !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope.ciphertext))
|
|
122
|
+
throw new storageErrors_1.StorageError("unreadable");
|
|
123
|
+
try {
|
|
124
|
+
return await protector.unprotect(envelope.ciphertext);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new storageErrors_1.StorageError("unreadable");
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
async set(key, value) {
|
|
131
|
+
const target = fileFor(key);
|
|
132
|
+
const temporary = `${target}.${(0, node_crypto_1.randomUUID)()}.tmp`;
|
|
133
|
+
try {
|
|
134
|
+
const ciphertext = await protector.protect(value);
|
|
135
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(ciphertext) || await protector.unprotect(ciphertext) !== value)
|
|
136
|
+
throw new storageErrors_1.StorageError("unavailable");
|
|
137
|
+
const envelope = JSON.stringify({ secureStorage: 1, scheme: "dpapi-current-user", ciphertext });
|
|
138
|
+
await files.mkdir(node_path_1.default.dirname(target));
|
|
139
|
+
await files.write(temporary, envelope);
|
|
140
|
+
await files.replace(temporary, target);
|
|
141
|
+
return { persistence: "persistent" };
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new storageErrors_1.StorageError("unavailable");
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
try {
|
|
148
|
+
await files.remove(temporary);
|
|
149
|
+
}
|
|
150
|
+
catch { /* Only encrypted abandoned data is possible. */ }
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
async delete(key) {
|
|
154
|
+
try {
|
|
155
|
+
await files.remove(fileFor(key));
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (!absent(error))
|
|
159
|
+
throw new storageErrors_1.StorageError("clear-failed");
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vanspod",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"bin": { "vanspod": "bin/vanspod.cjs" },
|
|
5
|
+
"engines": { "node": ">=24" },
|
|
6
|
+
"os": ["win32"],
|
|
7
|
+
"files": ["bin/", "dist/**/*.js", "!dist/test/**", "!dist/sea-*.js", "media/brand-logo.png", "media/brand-icon.ico", "media/fadou-superhero.png", "media/fadou-superhero-blink.png", "scripts/tray.ps1", "scripts/uninstall.ps1", "README.md"],
|
|
8
|
+
"publishConfig": { "access": "public" },
|
|
9
|
+
"description": "本機 VansPod。學生用系統瀏覽器兌換課堂連線。",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "tsc -p . && node --test dist/test/*.test.js",
|
|
12
|
+
"typecheck": "tsc -p . --noEmit",
|
|
13
|
+
"build": "tsc -p .",
|
|
14
|
+
"start": "tsc -p . && node dist/entry.js",
|
|
15
|
+
"prepack": "npm run build",
|
|
16
|
+
"postinstall": "node bin/register.cjs",
|
|
17
|
+
"dist": "node scripts/dist.mjs",
|
|
18
|
+
"test:windows-exe": "npm run dist && node scripts/test-windows-exe.mjs",
|
|
19
|
+
"test:lifecycle": "tsc -p . && node --test dist/test/launcher.test.js",
|
|
20
|
+
"test:distribution": "npm run dist && node scripts/test-distribution.mjs"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^24.5.2",
|
|
24
|
+
"typescript": "^5.9.2"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"yaml": "^2.9.1"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/scripts/tray.ps1
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory = $true)]
|
|
3
|
+
[string]$Url,
|
|
4
|
+
[Parameter(Mandatory = $true)][string]$IconPath,
|
|
5
|
+
[Parameter(Mandatory = $true)][string]$StateFile
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
9
|
+
Add-Type -AssemblyName System.Drawing
|
|
10
|
+
|
|
11
|
+
$script:OpenUrl = $Url
|
|
12
|
+
$openPage = { Start-Process $script:OpenUrl }.GetNewClosure()
|
|
13
|
+
|
|
14
|
+
$notify = New-Object System.Windows.Forms.NotifyIcon
|
|
15
|
+
$notify.Icon = New-Object System.Drawing.Icon($IconPath)
|
|
16
|
+
$notify.Text = "VansPod"
|
|
17
|
+
$notify.Visible = $true
|
|
18
|
+
$notify.add_Click($openPage)
|
|
19
|
+
|
|
20
|
+
$menu = New-Object System.Windows.Forms.ContextMenuStrip
|
|
21
|
+
$open = $menu.Items.Add("開啟 VansPod")
|
|
22
|
+
$open.add_Click($openPage)
|
|
23
|
+
$stop = $menu.Items.Add("結束 VansPod")
|
|
24
|
+
$stop.add_Click({
|
|
25
|
+
try {
|
|
26
|
+
$state = Get-Content -LiteralPath $StateFile -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
27
|
+
$headers = @{Authorization = 'Bearer ' + $state.token}
|
|
28
|
+
$result = Invoke-RestMethod -Uri ($state.origin + '/_vanspod/stop') -Method Post -Headers $headers -ContentType 'application/json' -Body '{}' -TimeoutSec 20
|
|
29
|
+
if ($result.confirmation) {
|
|
30
|
+
$answer = [System.Windows.Forms.MessageBox]::Show($result.message, 'VansPod', [System.Windows.Forms.MessageBoxButtons]::YesNo)
|
|
31
|
+
if ($answer -eq [System.Windows.Forms.DialogResult]::Yes) { $result = Invoke-RestMethod -Uri ($state.origin + '/_vanspod/stop') -Method Post -Headers $headers -ContentType 'application/json' -Body '{"confirmed":true}' -TimeoutSec 20 }
|
|
32
|
+
}
|
|
33
|
+
if (-not $result.ok -and -not $result.confirmation) { [System.Windows.Forms.MessageBox]::Show($result.message, 'VansPod') | Out-Null }
|
|
34
|
+
} catch { [System.Windows.Forms.MessageBox]::Show('無法結束 VansPod,請開啟畫面後重試。', 'VansPod') | Out-Null }
|
|
35
|
+
})
|
|
36
|
+
$notify.ContextMenuStrip = $menu
|
|
37
|
+
|
|
38
|
+
try { [System.Windows.Forms.Application]::Run() }
|
|
39
|
+
finally { $notify.Visible = $false; $notify.Icon.Dispose(); $notify.Dispose(); $menu.Dispose() }
|
|
40
|
+
|