undergroundwires/privacy.sexy

View on GitHub
src/presentation/components/Code/TheCodeArea.vue

Summary

Maintainability
Test Coverage
<template>
  <SizeObserver
    v-non-collapsing
    @size-changed="sizeChanged()"
  >
    <!-- `data-test-highlighted-range` is a test hook for assessing highlighted text range -->
    <div
      :id="editorId"
      :data-test-highlighted-range="highlightedRange"
      class="code-area"
    />
  </SizeObserver>
</template>

<script lang="ts">
import {
  defineComponent, onUnmounted, onMounted, ref,
} from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import type { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import type { Script } from '@/domain/Executables/Script/Script';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory';
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import ace from './ace-importer';

export default defineComponent({
  components: {
    SizeObserver,
  },
  directives: {
    NonCollapsing,
  },
  props: {
    theme: {
      type: String,
      default: undefined,
    },
  },
  setup(props) {
    const { onStateChange, currentState } = injectKey((keys) => keys.useCollectionState);
    const { projectDetails } = injectKey((keys) => keys.useApplication);
    const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);

    const editorId = 'codeEditor';
    const highlightedRange = ref(0);

    let editor: ace.Ace.Editor | undefined;
    let currentMarkerId: number | undefined;

    onUnmounted(() => {
      destroyEditor();
    });

    onMounted(() => { // allow editor HTML to render
      onStateChange((newState) => {
        handleNewState(newState);
      }, { immediate: true });
    });

    function handleNewState(newState: IReadOnlyCategoryCollectionState) {
      destroyEditor();
      editor = initializeEditor(
        props.theme,
        editorId,
        newState.collection.scripting.language,
      );
      const appCode = newState.code;
      updateCode(appCode.current, newState.collection.scripting.language);
      events.unsubscribeAllAndRegister([
        appCode.changed.on((code) => handleCodeChange(code)),
      ]);
    }

    function updateCode(code: string, language: ScriptingLanguage) {
      const innerCode = code || getDefaultCode(language, projectDetails);
      editor?.setValue(innerCode, 1);
    }

    function handleCodeChange(event: ICodeChangedEvent) {
      removeCurrentHighlighting();
      updateCode(event.code, currentState.value.collection.scripting.language);
      if (event.addedScripts?.length > 0) {
        reactToChanges(event, event.addedScripts);
      } else if (event.changedScripts?.length > 0) {
        reactToChanges(event, event.changedScripts);
      }
    }

    function sizeChanged() {
      editor?.resize();
    }

    function destroyEditor() {
      editor?.destroy();
      editor = undefined;
    }

    function removeCurrentHighlighting() {
      if (!currentMarkerId) {
        return;
      }
      editor?.session.removeMarker(currentMarkerId);
      currentMarkerId = undefined;
      highlightedRange.value = 0;
    }

    function reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<Script>) {
      const positions = scripts
        .map((script) => event.getScriptPositionInCode(script));
      const start = Math.min(
        ...positions.map((position) => position.startLine),
      );
      const end = Math.max(
        ...positions.map((position) => position.endLine),
      );
      scrollToLine(end + 2);
      highlight(start, end);
    }

    function highlight(startRow: number, endRow: number) {
      const AceRange = ace.require('ace/range').Range;
      currentMarkerId = editor?.session.addMarker(
        new AceRange(startRow, 0, endRow, 0),
        'code-area__highlight',
        'fullLine',
      );
      highlightedRange.value = endRow - startRow;
    }

    function scrollToLine(row: number) {
      const column = editor?.session.getLine(row).length;
      if (column === undefined) {
        return;
      }
      editor?.gotoLine(row, column, true);
    }

    return {
      editorId,
      highlightedRange,
      sizeChanged,
    };
  },
});

function initializeEditor(
  theme: string | undefined,
  editorId: string,
  language: ScriptingLanguage,
): ace.Ace.Editor {
  theme = theme || 'github';
  const editor = ace.edit(editorId);
  const lang = getLanguage(language);
  editor.getSession().setMode(`ace/mode/${lang}`);
  editor.setTheme(`ace/theme/${theme}`);
  editor.setReadOnly(true);
  editor.setAutoScrollEditorIntoView(true);
  editor.setShowPrintMargin(false); // hides vertical line
  editor.getSession().setUseWrapMode(true); // So code is readable on mobile
  return editor;
}

function getLanguage(language: ScriptingLanguage) {
  switch (language) {
    case ScriptingLanguage.batchfile: return 'batchfile';
    case ScriptingLanguage.shellscript: return 'sh';
    default:
      throw new Error('unknown language');
  }
}

function getDefaultCode(language: ScriptingLanguage, project: ProjectDetails): string {
  return new CodeBuilderFactory()
    .create(language)
    .appendCommentLine(`${project.name} — ${project.slogan}`)
    /*
      Keep the slogan without a period for impact and continuity.
      Slogans should be punchy and memorable, not punctuated like full sentences.
    */
    .appendCommentLine(' 🔐 Enforce privacy & security best-practices on Windows, macOS and Linux.')
    .appendLine()
    .appendCommentLine('-- 🤔 How to use')
    .appendCommentLine(' 📙 Start by exploring different categories and choosing different tweaks.')
    .appendCommentLine(' 📙 On top left, you can apply predefined selections for privacy level you\'d like.')
    .appendCommentLine(' 📙 After you choose any tweak, you can download or copy to execute your script.')
    .appendCommentLine(' 📙 Come back regularly to apply latest version for stronger privacy and security.')
    .appendLine()
    .appendCommentLine(`-- 🧐 Why ${project.name}`)
    .appendCommentLine(' ✔️ Rich tweak pool to harden security & privacy of the OS and other software on it.')
    .appendCommentLine(' ✔️ No need to run any compiled software on your system, just run the generated scripts.')
    .appendCommentLine(' ✔️ Have full visibility into what the tweaks do as you enable them.')
    .appendCommentLine(' ✔️ Open-source and free (both free as in beer and free as in speech).')
    .appendCommentLine(' ✔️ Committed to your safety with strong security measures.')
    .toString();
}

</script>

<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;

:deep() {
  .code-area {
    min-height: 200px;
    width: 100%;
    height: 100%;
    overflow: auto;
    font-size: $font-size-absolute-small;
    font-family: $font-family-monospace;
    &__highlight {
      background-color: $color-secondary-light;
      position: absolute;
    }
  }
}
</style>