privacy-brush 0.0.4 → 1.0.1
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/.editorconfig +19 -0
- package/.github/workflows/typecheck.yml +32 -31
- package/.nvmrc +1 -0
- package/README-zh.md +496 -496
- package/README.md +314 -302
- package/package.json +2 -1
- package/src/cli.mjs +174 -154
- package/src/cli.test.mjs +169 -28
- package/src/index.mjs +256 -162
- package/src/lib/config.mjs +14 -8
- package/src/lib/parse-args.mjs +79 -0
- package/src/type.ts +19 -17
- package/test/fixtures/{terminal_log.txt → terminal_log.md} +24 -13
package/src/index.mjs
CHANGED
|
@@ -1,162 +1,256 @@
|
|
|
1
|
-
/** @import { IConfig, IPattern, IPatternName } from "./type.js" */
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
* @param {string}
|
|
30
|
-
* @param {string}
|
|
31
|
-
* @
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
* @param {string}
|
|
48
|
-
* @
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
1
|
+
/** @import { IConfig, IPattern, IPatternName } from "./type.js" */
|
|
2
|
+
|
|
3
|
+
import { createRequire } from "node:module"
|
|
4
|
+
import { defaultConfig } from "./lib/config.mjs"
|
|
5
|
+
import { verbose } from "./lib/parse-args.mjs"
|
|
6
|
+
|
|
7
|
+
export class PrivacyBrush {
|
|
8
|
+
/**
|
|
9
|
+
* @param {IConfig} [config]
|
|
10
|
+
*/
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.config = {
|
|
13
|
+
...defaultConfig,
|
|
14
|
+
...config,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get defaultSensitivePatterns() {
|
|
19
|
+
/** @type {IPattern[]} */
|
|
20
|
+
const allPatterns = [
|
|
21
|
+
// 操作系统版本 (Windows 10.0.19045.6456)
|
|
22
|
+
{
|
|
23
|
+
/** @type {IPatternName} */
|
|
24
|
+
name: "windows_version",
|
|
25
|
+
// match both Chinese "版本" and English "Version"
|
|
26
|
+
regex: /(\[(?:版本|Version)\s+)(\d+\.\d+\.\d+\.\d+)(\])/i,
|
|
27
|
+
/**
|
|
28
|
+
* Handle windows version masking.
|
|
29
|
+
* @param {string} match
|
|
30
|
+
* @param {string} prefix
|
|
31
|
+
* @param {string} version
|
|
32
|
+
* @param {string} suffix
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
replacer: (match, prefix, version, suffix) => {
|
|
36
|
+
return prefix + this.maskVersion(version) + suffix
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// 浏览器版本 (Chrome 144.0.7559.60)
|
|
41
|
+
{
|
|
42
|
+
/** @type {IPatternName} */
|
|
43
|
+
name: "browser_version",
|
|
44
|
+
regex: /(Chrome|Edge)\s+(\d+\.\d+\.\d+\.\d+)/gi,
|
|
45
|
+
/**
|
|
46
|
+
* Handle browser version masking.
|
|
47
|
+
* @param {string} match
|
|
48
|
+
* @param {string} browser
|
|
49
|
+
* @param {string} version
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
replacer: (match, browser, version) => {
|
|
53
|
+
return `${browser} ${this.maskVersion(version)}`
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// IP地址
|
|
58
|
+
{
|
|
59
|
+
/** @type {IPatternName} */
|
|
60
|
+
name: "ip_address",
|
|
61
|
+
regex: /\b(\d{1,3}\.){3}\d{1,3}\b/g,
|
|
62
|
+
/**
|
|
63
|
+
* Handle IP address masking.
|
|
64
|
+
* @param {string} match
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
replacer: match => {
|
|
68
|
+
return this.maskVersion(match)
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// user name `/Users/test/code/` => `/Users/████/code/`
|
|
73
|
+
{
|
|
74
|
+
/** @type {IPatternName} */
|
|
75
|
+
name: "user_name_in_path",
|
|
76
|
+
regex: /\/Users\/([^\/]+)\//g,
|
|
77
|
+
/**
|
|
78
|
+
* Handle user name in file path masking.
|
|
79
|
+
* @param {string} match
|
|
80
|
+
* @param {string} userName
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
replacer: (match, userName) => {
|
|
84
|
+
return match.replace(userName, this.maskChar.repeat(userName.length))
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
return allPatterns
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse custom pattern inputs into maskable patterns.
|
|
94
|
+
* Accepts strings like '/sk-[a-z0-9]{20,}/i' or raw pattern bodies.
|
|
95
|
+
* @returns {IPattern[]}
|
|
96
|
+
*/
|
|
97
|
+
get customSensitivePatterns() {
|
|
98
|
+
const customPatterns = this.config.customPatterns || []
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse string pattern to RegExp.
|
|
102
|
+
* @param {string | RegExp} pattern
|
|
103
|
+
* @returns {RegExp} returns Parsed RegExp with global flag
|
|
104
|
+
* @example
|
|
105
|
+
* '/\\d{1,}/' => /\d{1,}/g
|
|
106
|
+
*/
|
|
107
|
+
const parse = pattern => {
|
|
108
|
+
if (pattern instanceof RegExp) {
|
|
109
|
+
// always replace globally
|
|
110
|
+
const flags = pattern.flags.includes("g")
|
|
111
|
+
? pattern.flags
|
|
112
|
+
: `${pattern.flags}g`
|
|
113
|
+
|
|
114
|
+
return new RegExp(pattern.source, flags)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const patternStr = pattern.trim()
|
|
118
|
+
// `/\d{1,}/g`.match(/^\/(.*)\/(\w*)$/) => [/\d{1,}/g, '\d{1,}', 'g']
|
|
119
|
+
const matches = patternStr.match(/^\/(.*)\/(\w*)$/)
|
|
120
|
+
|
|
121
|
+
if (matches) {
|
|
122
|
+
const body = matches[1]
|
|
123
|
+
let flags = matches[2] ?? ""
|
|
124
|
+
|
|
125
|
+
if (!flags.includes("g")) flags += "g"
|
|
126
|
+
|
|
127
|
+
return new RegExp(body, flags)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// treat as literal body
|
|
131
|
+
return new RegExp(patternStr, "g")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return customPatterns.map((pattern, i) => {
|
|
135
|
+
const regex = parse(pattern)
|
|
136
|
+
return {
|
|
137
|
+
name: `custom_${i + 1}`,
|
|
138
|
+
regex,
|
|
139
|
+
replacer: (match, group) => {
|
|
140
|
+
// const groups = rest.slice(0, -2) // last two are offset and input string
|
|
141
|
+
verbose && console.log("custom pattern match:", { match, group })
|
|
142
|
+
|
|
143
|
+
if (typeof group === "string") {
|
|
144
|
+
return match.replace(group, this.maskChar.repeat(group.length))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return this.maskChar.repeat(match.length)
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get maskChar() {
|
|
154
|
+
return this.config.maskChar ?? defaultConfig.maskChar
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
*
|
|
159
|
+
* @returns {IPattern[]}
|
|
160
|
+
*/
|
|
161
|
+
get sensitivePatterns() {
|
|
162
|
+
const base = this.defaultSensitivePatterns.filter(({ name }) =>
|
|
163
|
+
this.config.maskPatternNames?.includes(name),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const customSensitivePatterns = this.customSensitivePatterns
|
|
167
|
+
verbose &&
|
|
168
|
+
console.log(
|
|
169
|
+
"🚀 ~ PrivacyBrush ~ sensitivePatterns ~ customSensitivePatterns:",
|
|
170
|
+
customSensitivePatterns,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return base.concat(customSensitivePatterns)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Mask a version string.
|
|
178
|
+
* @param {string} version
|
|
179
|
+
* @returns {string}
|
|
180
|
+
*/
|
|
181
|
+
maskVersion(version) {
|
|
182
|
+
const parts = version.split(".")
|
|
183
|
+
|
|
184
|
+
if (!this.config.preserveFirstPart) {
|
|
185
|
+
return parts.map(part => this.maskChar.repeat(part.length)).join(".")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return parts
|
|
189
|
+
.map((part, index) => {
|
|
190
|
+
return index === 0 ? part : this.maskChar.repeat(part.length)
|
|
191
|
+
})
|
|
192
|
+
.join(".")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
*
|
|
197
|
+
* @param {string} text
|
|
198
|
+
* @returns
|
|
199
|
+
*/
|
|
200
|
+
maskText(text) {
|
|
201
|
+
verbose && console.log(`[PrivacyBrush] Masking text: |${text}|`)
|
|
202
|
+
|
|
203
|
+
let result = text
|
|
204
|
+
|
|
205
|
+
this.sensitivePatterns.forEach(pattern => {
|
|
206
|
+
verbose && console.log("pattern:", pattern)
|
|
207
|
+
result = result.replace(pattern.regex, pattern.replacer)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return result
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 批量处理文件
|
|
215
|
+
* @param {string} inputPath
|
|
216
|
+
* @param {string} [outputPath]
|
|
217
|
+
* @returns
|
|
218
|
+
*/
|
|
219
|
+
maskFile(inputPath, outputPath) {
|
|
220
|
+
try {
|
|
221
|
+
// Delay-load fs to avoid startup cost when this method is not used
|
|
222
|
+
const require = createRequire(import.meta.url)
|
|
223
|
+
const fs = require("node:fs")
|
|
224
|
+
|
|
225
|
+
const content = fs.readFileSync(inputPath, "utf8")
|
|
226
|
+
const maskedContent = this.maskText(content)
|
|
227
|
+
|
|
228
|
+
if (outputPath) {
|
|
229
|
+
fs.writeFileSync(outputPath, maskedContent, "utf8")
|
|
230
|
+
verbose && console.log(`Masked file saved to: ${outputPath}`)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return maskedContent
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.error(
|
|
236
|
+
"Error processing file:",
|
|
237
|
+
error instanceof Error ? error.message : String(error),
|
|
238
|
+
)
|
|
239
|
+
throw error
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 实时流处理
|
|
244
|
+
async createMaskStream() {
|
|
245
|
+
const { Transform } = await import("node:stream")
|
|
246
|
+
|
|
247
|
+
return new Transform({
|
|
248
|
+
transform: (chunk, encoding, callback) => {
|
|
249
|
+
const text = String(chunk)
|
|
250
|
+
|
|
251
|
+
const masked = this.maskText(text)
|
|
252
|
+
callback(null, masked)
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
}
|
package/src/lib/config.mjs
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @
|
|
3
|
-
*/
|
|
4
|
-
export const defaultConfig = {
|
|
5
|
-
maskChar: "█",
|
|
6
|
-
preserveFirstPart: true, // 是否保留版本号的第一部分
|
|
7
|
-
maskPatternNames: [
|
|
8
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @satisfies {import('../type.js').IConfig}
|
|
3
|
+
*/
|
|
4
|
+
export const defaultConfig = {
|
|
5
|
+
maskChar: "█",
|
|
6
|
+
preserveFirstPart: true, // 是否保留版本号的第一部分
|
|
7
|
+
maskPatternNames: [
|
|
8
|
+
"windows_version",
|
|
9
|
+
"browser_version",
|
|
10
|
+
"ip_address",
|
|
11
|
+
"user_name_in_path",
|
|
12
|
+
],
|
|
13
|
+
customPatterns: [],
|
|
14
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { parseArgs } from "node:util"
|
|
2
|
+
|
|
3
|
+
// get config from command line arguments use native `parseArgs`
|
|
4
|
+
// node src/cli.mjs --mask X --preserve-first false
|
|
5
|
+
const args = process.argv.slice(2)
|
|
6
|
+
// console.log("args:", args) // args: [ '--mask', 'X', '--preserve-first', 'false' ]
|
|
7
|
+
|
|
8
|
+
export const verbose = args.includes("--verbose")
|
|
9
|
+
|
|
10
|
+
export const parsedResult = safeCall(() =>
|
|
11
|
+
parseArgs({
|
|
12
|
+
allowPositionals: true,
|
|
13
|
+
allowNegative: true,
|
|
14
|
+
|
|
15
|
+
args,
|
|
16
|
+
|
|
17
|
+
options: {
|
|
18
|
+
// input file to mask
|
|
19
|
+
"input-file": {
|
|
20
|
+
type: "string",
|
|
21
|
+
short: "i",
|
|
22
|
+
},
|
|
23
|
+
// custom regex pattern(s) to mask
|
|
24
|
+
pattern: {
|
|
25
|
+
type: "string",
|
|
26
|
+
short: "r",
|
|
27
|
+
multiple: true,
|
|
28
|
+
},
|
|
29
|
+
// output file to write masked content
|
|
30
|
+
"output-file": {
|
|
31
|
+
type: "string",
|
|
32
|
+
short: "o",
|
|
33
|
+
},
|
|
34
|
+
mask: {
|
|
35
|
+
type: "string",
|
|
36
|
+
short: "m",
|
|
37
|
+
default: "█",
|
|
38
|
+
},
|
|
39
|
+
"preserve-first": {
|
|
40
|
+
type: "boolean",
|
|
41
|
+
short: "p",
|
|
42
|
+
default: true,
|
|
43
|
+
},
|
|
44
|
+
// help
|
|
45
|
+
help: {
|
|
46
|
+
type: "boolean",
|
|
47
|
+
short: "h",
|
|
48
|
+
},
|
|
49
|
+
// verbose
|
|
50
|
+
verbose: {
|
|
51
|
+
type: "boolean",
|
|
52
|
+
},
|
|
53
|
+
// version
|
|
54
|
+
version: {
|
|
55
|
+
type: "boolean",
|
|
56
|
+
short: "v",
|
|
57
|
+
},
|
|
58
|
+
// list built-in patterns
|
|
59
|
+
"list-patterns": {
|
|
60
|
+
type: "boolean",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @template T
|
|
68
|
+
* @param {(...args: any[]) => T} fn
|
|
69
|
+
* @returns {[null, T] | [Error, null]}
|
|
70
|
+
*/
|
|
71
|
+
function safeCall(fn) {
|
|
72
|
+
try {
|
|
73
|
+
const result = fn()
|
|
74
|
+
return [null, result]
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// @ts-expect-error
|
|
77
|
+
return [error, null]
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/type.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
export type IConfig = {
|
|
2
|
-
maskChar?: string
|
|
3
|
-
preserveFirstPart?: boolean
|
|
4
|
-
maskPatternNames?: IPatternName[]
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
| "
|
|
10
|
-
| "
|
|
11
|
-
| "
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
export type IConfig = {
|
|
2
|
+
maskChar?: string
|
|
3
|
+
preserveFirstPart?: boolean
|
|
4
|
+
maskPatternNames?: IPatternName[]
|
|
5
|
+
customPatterns?: (string | RegExp)[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type IPatternName =
|
|
9
|
+
| "windows_version"
|
|
10
|
+
| "browser_version"
|
|
11
|
+
| "android_version"
|
|
12
|
+
| "ip_address"
|
|
13
|
+
| (string & {})
|
|
14
|
+
|
|
15
|
+
export type IPattern = {
|
|
16
|
+
name: IPatternName
|
|
17
|
+
regex: RegExp
|
|
18
|
+
replacer: (substring: string, ...args: any[]) => string
|
|
19
|
+
}
|
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
1
|
+
# h1
|
|
2
|
+
|
|
3
|
+
## flutter devices
|
|
4
|
+
|
|
5
|
+
❯ flutter devices
|
|
6
|
+
Flutter assets will be downloaded from <https://storage.flutter-io.cn>. Make sure you trust this source!
|
|
7
|
+
Found 4 connected devices:
|
|
8
|
+
sdk gphone64 x86 64 (mobile) • emulator-5554 • android-x64 • Android 16 (API 36) (emulator)
|
|
9
|
+
Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.12345.6456]
|
|
10
|
+
Chrome (web) • chrome • web-javascript • Google Chrome 144.0.1234.60
|
|
11
|
+
Edge (web) • edge • web-javascript • Microsoft Edge 144.0.1234.82
|
|
12
|
+
|
|
13
|
+
Run "flutter emulators" to list and start any available device emulators.
|
|
14
|
+
|
|
15
|
+
If you expected another device to be detected, please run "flutter doctor" to diagnose potential issues. You may also try
|
|
16
|
+
increasing the time to wait for connected devices with the "--device-timeout" flag. Visit <https://flutter.dev/setup/> for
|
|
17
|
+
troubleshooting tips.
|
|
18
|
+
|
|
19
|
+
## log
|
|
20
|
+
|
|
21
|
+
DEBUG: User login from IP 192.168.1.100
|
|
22
|
+
DEBUG: Session ID: abc123def456
|
|
23
|
+
DEBUG: Browser: Chrome/144.0.1234.60
|
|
24
|
+
DEBUG: OS: Windows 10.0.12345
|