Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions ide/electron/encoding.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// 이 바이트들을 텍스트 편집기에 실어도 되는가 — **순수한 판정**.
//
// readFile 이 무조건 fs.readFile(abs, "utf8") 이었다. UTF-8 이 아닌 파일은 디코딩에서
// 깨진 글자(U+FFFD)로 바뀌고, 그 상태로 저장하면 **원본이 파괴된다.** 되돌릴 방법이
// 없다.
//
// 실제로 재현했다. UTF-16 파일은 열고 Ctrl+S 만 눌러도 16바이트가 20바이트가 됐다.
// CP949 로 저장된 한글 파일은 화면에 `// �ȳ� ����` 로 뜨고, 거기서 한 글자만 고쳐
// 저장하면 파일 전체가 사라진다. 윈도우에서 아주 흔한 인코딩이다.
//
// 다른 인코딩을 **읽어 주는 것**은 기능이고, 조용히 부수지 않는 것은 그 전에 지켜야
// 할 일이다. 그래서 지금은 판정만 하고, 아니면 열지 않고 이유를 말한다.

/**
* @returns null 이면 UTF-8 텍스트로 안전하다. 아니면 왜 아닌지.
* "utf16le" | "utf16be" — 바이트 순서 표식이 붙은 UTF-16
* "binary" — NUL 바이트가 있다(텍스트가 아니다)
* "not-utf8" — UTF-8 로 해석되지 않는 바이트가 있다(CP949·Shift-JIS·Latin-1 …)
*/
function detect(buf) {
if (!buf || typeof buf.length !== "number") return null;
if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) return "utf16le";
if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) return "utf16be";
// NUL 은 텍스트 파일에 있을 이유가 없다. UTF-16 은 위에서 걸렀으므로 여기 오는
// NUL 은 진짜 바이너리다(확장자만 텍스트인 파일이 흔하다).
if (buf.indexOf(0) >= 0) return "binary";
// 왕복시켜 본다 — 디코딩이 손실 없이 됐다면 다시 인코딩한 바이트가 같아야 한다.
// 깨진 바이트는 U+FFFD 로 바뀌므로 길이부터 달라진다.
const round = Buffer.from(buf.toString("utf8"), "utf8");
return round.equals(buf) ? null : "not-utf8";
}

/** IPC 로 넘길 오류 메시지. 렌더러가 앞부분을 보고 안내 문구를 고른다. */
const PREFIX = "SCHUTZ_ENCODING:";
function errorFor(kind) { return PREFIX + kind; }
function kindOf(message) {
const s = String(message || "");
const i = s.indexOf(PREFIX);
return i < 0 ? null : s.slice(i + PREFIX.length).trim().split(/\s/)[0];
}

module.exports = { detect, errorFor, kindOf, PREFIX };
5 changes: 5 additions & 0 deletions ide/electron/encoding.d.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// encoding.cjs 의 타입. 메인 프로세스 코드지만 판정 부분은 테스트에서 그대로 부른다.
export declare function detect(buf: Buffer | null | undefined): null | "utf16le" | "utf16be" | "binary" | "not-utf8";
export declare function errorFor(kind: string): string;
export declare function kindOf(message: unknown): string | null;
export declare const PREFIX: string;
9 changes: 8 additions & 1 deletion ide/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -285,13 +285,20 @@ ipcMain.handle("schutz:readTree", async (_e, root) => {
return { root, name: path.basename(root), entries, branch, truncated: entries.length >= MAX_ENTRIES || depthCapped };
});

const encoding = require("./encoding.cjs");
ipcMain.handle("schutz:readFile", async (_e, root, rel) => {
const abs = safeJoin(root, rel);
const st = await fs.stat(abs);
if (st.size > MAX_FILE_BYTES) {
throw new Error("파일이 너무 큽니다 (" + Math.round(st.size / 1024) + " KB)");
}
return await fs.readFile(abs, "utf8");
// 바이트로 읽어 UTF-8 인지 먼저 본다. 예전엔 무조건 "utf8" 로 디코딩해서, UTF-8 이
// 아닌 파일은 깨진 글자로 열리고 **저장하는 순간 원본이 파괴됐다.** 되돌릴 수 없다.
// (UTF-16 파일은 열고 Ctrl+S 만 눌러도 바이트가 바뀌는 것을 확인했다.)
const buf = await fs.readFile(abs);
const kind = encoding.detect(buf);
if (kind) throw new Error(encoding.errorFor(kind));
return buf.toString("utf8");
});

/** 원자적 쓰기 — 같은 디렉터리에 임시 파일로 쓴 뒤 rename. 도중에 죽어도 반쪽짜리 파일이 남지 않는다.
Expand Down
14 changes: 13 additions & 1 deletion ide/src/editor/MonacoPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import { getEditorPrefs, codeFontStack } from "../settings";
import * as projectModels from "./projectModels";
import { t } from "../i18n";
import { useLang } from "../i18n/useLang";

/** 메인이 "UTF-8 이 아니라 안 열었다" 고 답한 경우를 사람 말로 바꾼다.
* 왜 안 열리는지 말해 주지 않으면 그냥 고장 난 편집기로 보인다. */
function encodingMessage(raw: string): string {
const mark = "SCHUTZ_ENCODING:";
const i = raw.indexOf(mark);
if (i < 0) return raw;
const kind = raw.slice(i + mark.length).trim().split(/\s/)[0];
if (kind === "binary") return t("enc.binary");
if (kind === "utf16le" || kind === "utf16be") return t("enc.utf16");
return t("enc.notUtf8");
}
// @ts-ignore — monaco-vim 타입 미제공
import { initVimMode } from "monaco-vim";

Expand Down Expand Up @@ -252,7 +264,7 @@ function MonacoPaneImpl({ root, rel, onDirtyChange, onSaved, onConfirm, onStatus
})
.catch(e => {
if (disposed) return;
setError(e instanceof Error ? e.message : String(e));
setError(encodingMessage(e instanceof Error ? e.message : String(e)));
setState("error");
});
}
Expand Down
59 changes: 59 additions & 0 deletions ide/src/engine/encoding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { detect, errorFor, kindOf, PREFIX } from "../../electron/encoding.cjs";

const U8 = (s: string) => Buffer.from(s, "utf8");

describe("detect", () => {
it("평범한 UTF-8 은 통과", () => {
expect(detect(U8("export const a = 1;\n"))).toBeNull();
expect(detect(U8("한글도 UTF-8 이면 괜찮다\n"))).toBeNull();
expect(detect(U8(""))).toBeNull();
});

it("UTF-8 BOM 은 UTF-8 이다 — 표식이 있다고 막으면 안 된다", () => {
expect(detect(Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), U8("a\n")]))).toBeNull();
});

it("UTF-16 은 바이트 순서 표식으로 알아본다", () => {
expect(detect(Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from("hi", "utf16le")]))).toBe("utf16le");
expect(detect(Buffer.from([0xFE, 0xFF, 0x00, 0x68]))).toBe("utf16be");
});

it("CP949 한글은 UTF-8 로 해석되지 않는다 — 열면 깨지고 저장하면 원본이 사라진다", () => {
// "안녕" (EUC-KR)
expect(detect(Buffer.from([0xBE, 0xC8, 0xB3, 0xE7]))).toBe("not-utf8");
});

it("Latin-1 도 마찬가지", () => {
expect(detect(Buffer.from([0x63, 0x61, 0x66, 0xE9]))).toBe("not-utf8"); // café
});

it("NUL 이 있으면 텍스트가 아니다 — 확장자만 텍스트인 파일이 흔하다", () => {
expect(detect(Buffer.from([0x61, 0x00, 0x62]))).toBe("binary");
});

it("UTF-16 을 바이너리보다 먼저 알아본다 — 둘 다 NUL 이 있다", () => {
expect(detect(Buffer.from([0xFF, 0xFE, 0x68, 0x00]))).toBe("utf16le");
});

it("빈 값에도 던지지 않는다", () => {
expect(detect(null as any)).toBeNull();
expect(detect(undefined as any)).toBeNull();
});
});

describe("errorFor / kindOf", () => {
it("왕복한다", () => {
expect(kindOf(errorFor("not-utf8"))).toBe("not-utf8");
expect(kindOf(errorFor("utf16le"))).toBe("utf16le");
});

it("Electron 이 앞에 덧붙여도 찾는다 — IPC 오류는 감싸여서 온다", () => {
expect(kindOf("Error invoking remote method 'schutz:readFile': Error: " + PREFIX + "binary")).toBe("binary");
});

it("다른 오류는 null", () => {
expect(kindOf("파일이 너무 큽니다 (5000 KB)")).toBeNull();
expect(kindOf(undefined)).toBeNull();
});
});
9 changes: 9 additions & 0 deletions ide/src/i18n/dict/enc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// enc 도메인 — UTF-8 이 아닌 파일을 열려고 했을 때.
//
// 예전엔 무조건 UTF-8 로 디코딩해서 깨진 글자로 열렸고, 저장하는 순간 원본이
// 파괴됐다. 이제 열지 않고 이유를 말한다 — 왜 안 되는지 모르면 고장으로 보인다.
export const dict: Record<string, { ko: string; en: string; de: string; ja: string }> = {
"enc.notUtf8": { ko: "이 파일은 UTF-8 이 아닙니다. 열면 글자가 깨지고 저장하면 원본이 사라지므로 열지 않았습니다. UTF-8 로 변환한 뒤 다시 열어 주세요.", en: "This file is not UTF-8. Opening it would garble the text and saving would destroy the original, so it was not opened. Convert it to UTF-8 and try again.", de: "Diese Datei ist nicht UTF-8. Das Öffnen würde den Text zerstören und das Speichern das Original vernichten — sie wurde nicht geöffnet. Bitte in UTF-8 konvertieren.", ja: "このファイルは UTF-8 ではありません。開くと文字化けし、保存すると原本が失われるため開きませんでした。UTF-8 に変換してから開いてください。" },
"enc.utf16": { ko: "이 파일은 UTF-16 입니다. 아직 UTF-8 만 다룰 수 있어 열지 않았습니다 — 열었다면 저장하는 순간 원본이 사라졌을 것입니다.", en: "This file is UTF-16. Only UTF-8 is supported, so it was not opened — saving it would have destroyed the original.", de: "Diese Datei ist UTF-16. Es wird nur UTF-8 unterstützt, daher wurde sie nicht geöffnet — beim Speichern wäre das Original verloren gegangen.", ja: "このファイルは UTF-16 です。現在 UTF-8 のみ対応のため開きませんでした — 保存していれば原本が失われていました。" },
"enc.binary": { ko: "텍스트 파일이 아닙니다(NUL 바이트가 있습니다). 편집기로 열면 내용이 망가집니다.", en: "This is not a text file (it contains NUL bytes). Opening it in the editor would corrupt it.", de: "Das ist keine Textdatei (sie enthält NUL-Bytes). Im Editor zu öffnen würde sie beschädigen.", ja: "テキストファイルではありません(NUL バイトがあります)。エディターで開くと壊れます。" },
};
3 changes: 2 additions & 1 deletion ide/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { dict as d_runfile } from "./dict/runfile";
import { dict as d_confirm } from "./dict/confirm";
import { dict as d_extask } from "./dict/extask";
import { dict as d_extview } from "./dict/extview";
import { dict as d_enc } from "./dict/enc";
import { dict as d_flowtree } from "./dict/flowtree";
import { dict as d_dbg } from "./dict/dbg";
import { dict as d_mcpui } from "./dict/mcpui";
Expand Down Expand Up @@ -44,7 +45,7 @@ export type Msg = { ko: string; en: string; de: string; ja: string };

export const MESSAGES: Record<string, Msg> = {
...d_dap, ...d_data, ...d_exth, ...d_mcpc, ...d_media, ...d_model, ...d_mono, ...d_oai, ...d_reg, ...d_key,
...d_confirm, ...d_extask, ...d_extview, ...d_runfile, ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp,
...d_confirm, ...d_extask, ...d_extview, ...d_enc, ...d_runfile, ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp,
...d_sc1, ...d_sc2, ...d_sc3, ...d_sc4, ...d_sc5, ...d_eng, ...d_plug, ...d_review, ...d_cloud,
// ── 공통 ────────────────────────────────────────────────
"common.next": { ko: "다음", en: "Next", de: "Weiter", ja: "次へ" },
Expand Down
Loading