,
24 | toggle: @escaping () -> Void,
25 | label: LocalizedStringKey,
26 | info: LocalizedStringKey?,
27 | padding: CGFloat?,
28 | animation: Bool,
29 | @ViewBuilder content: @escaping () -> Content
30 | ) {
31 | self.toggleVar = toggleVar
32 | self.toggle = toggle
33 | self.label = label
34 | self.info = info
35 | self.padding = padding
36 | self.animation = animation
37 | self.content = content
38 | }
39 |
40 | var body: some View {
41 | DisclosureGroup(isExpanded: toggleVar) {
42 | content()
43 | .padding(.all, self.padding == nil ? Spacing.xl : self.padding)
44 | } label: {
45 | HStack(alignment: .center) {
46 | Text(label)
47 | .font(.subheadline)
48 | .fontWeight(.medium)
49 | .onTapGesture {
50 | if animation {
51 | withAnimation {
52 | toggle()
53 | }
54 | } else {
55 | toggle()
56 | }
57 | }
58 | .padding(.trailing, Spacing.m)
59 |
60 | Spacer()
61 |
62 | if self.info != nil {
63 | Text(info!)
64 | .font(.footnote)
65 | .fontWeight(.light)
66 | .foregroundColor(AppColor.Creme)
67 | .frame(maxWidth: 180)
68 | .onTapGesture {
69 | if animation {
70 | withAnimation {
71 | toggle()
72 | }
73 | } else {
74 | toggle()
75 | }
76 | }
77 | .help(info!)
78 | }
79 | }
80 | .padding(.leading, Spacing.m)
81 | .fixedSize(horizontal: false, vertical: true)
82 | }
83 | }
84 | }
85 |
86 | struct GroupView_Previews: PreviewProvider {
87 | static var previews: some View {
88 | GroupView(toggleVar: .constant(false), toggle: {}, label: "Test", info: nil, padding: nil, animation: true) {
89 | EmptyView()
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/ScriptManager/Views/Tags/TagsListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TagsListView.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 27.03.23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct TagsListView: View {
11 | @State private var vm = TagViewModel()
12 | @State private var showTags = true
13 |
14 | var body: some View {
15 | if (vm.tags.count > 0) {
16 | VStack(alignment: .leading, spacing: Spacing.l) {
17 | ScrollView(.horizontal) {
18 | HStack(spacing: Spacing.l) {
19 | ForEach(vm.tags, id: \.id) { tag in
20 | if tag.id != EmptyTag.id {
21 | Button {
22 | withAnimation() {
23 | if tag.id == vm.selectedTag {
24 | vm.setActiveTag(nil)
25 | } else {
26 | vm.setActiveTag(tag.id)
27 | }
28 | }
29 | } label: {
30 | let badgeColor = try? ColorConverter.decodeColor(from: tag.badgeColor)
31 |
32 | BadgeView(
33 | color: badgeColor ?? AppColor.Primary,
34 | title: tag.name,
35 | active: tag.id == vm.selectedTag
36 | )
37 | }
38 | .buttonStyle(.plain)
39 | }
40 | }
41 | }
42 | .padding(.bottom, Spacing.l + 5)
43 | // -- Soft List ending
44 | // .padding(.trailing, 50)
45 | }
46 | // .mask(
47 | // HStack(spacing: 0) {
48 | // // Middle
49 | // Rectangle().fill(Color.black)
50 | //
51 | // // Right gradient
52 | // LinearGradient(gradient:
53 | // Gradient(
54 | // colors: [Color.black, Color.black.opacity(0)]),
55 | // startPoint: .leading, endPoint: .trailing
56 | // )
57 | // .frame(width: 80)
58 | // }
59 | // )
60 | }
61 | .padding(.horizontal, Spacing.l)
62 | }
63 | }
64 | }
65 |
66 | struct TagsListView_Previews: PreviewProvider {
67 | static var previews: some View {
68 | TagsListView()
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/ScriptManager/ViewModels/TagViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TagViewModel.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 22.03.23.
6 | //
7 |
8 | import Foundation
9 | import Resolver
10 | import SwiftUI
11 |
12 | @Observable
13 | class TagViewModel {
14 | @LazyInjected @ObservationIgnored private var scriptHandler: ScriptHandlerProtocol
15 | @LazyInjected @ObservationIgnored private var alertHandler: AlertHandlerProtocol
16 | @LazyInjected @ObservationIgnored private var hintHandler: HintHandlerProtocol
17 | @LazyInjected @ObservationIgnored var modalHandler: ModalHandlerProtocol
18 | @LazyInjected @ObservationIgnored var tagHandler: TagHandlerProtocol
19 |
20 | var tags: [Tag] {
21 | tagHandler.tags
22 | }
23 |
24 | var selectedTag: UUID? {
25 | tagHandler.selectedTag
26 | }
27 |
28 | var editMode: Bool {
29 | tagHandler.editMode
30 | }
31 |
32 | @MainActor
33 | func saveTag() {
34 | do {
35 | let colorData = try ColorConverter.encodeColor(color: tagHandler.editColor)
36 | let newTag: Tag = Tag(name: tagHandler.editTag.name, badgeColor: colorData)
37 | tagHandler.tags.append(newTag)
38 | tagHandler.saveTags()
39 |
40 | resetForm()
41 |
42 | modalHandler.hideModal()
43 | hintHandler.showHint(String(localized: "save-tag-success"), type: .success)
44 | } catch {
45 | debugPrint("Failed to save new tag: \(error)")
46 | hintHandler.showHint(String(localized: "save-tag-failed"), type: .error)
47 | }
48 | }
49 |
50 | @MainActor
51 | func saveChangedTag() {
52 | do {
53 | let editTag = tagHandler.editTag
54 | let index: Int? = tagHandler.tags.firstIndex(where: { $0.id == editTag.id })
55 |
56 | guard let index else { return }
57 | var selectedTag = tagHandler.tags[index]
58 | selectedTag.name = editTag.name
59 |
60 | let colorData = try ColorConverter.encodeColor(color: tagHandler.editColor)
61 | selectedTag.badgeColor = colorData
62 |
63 | tagHandler.tags[index] = selectedTag
64 | tagHandler.saveTags()
65 |
66 | tagHandler.editMode = false
67 | resetForm()
68 |
69 | modalHandler.hideModal()
70 | hintHandler.showHint(String(localized: "save-edit-tag-success"), type: .success)
71 | } catch {
72 | hintHandler.showHint(String(localized: "save-changed-tag-failed"), type: .error)
73 | }
74 | }
75 |
76 | func setActiveTag(_ uuid: UUID?) {
77 | tagHandler.selectedTag = uuid
78 |
79 | if let uuid {
80 | // Set active tag
81 | scriptHandler.scripts = scriptHandler.savedScripts.filter({ $0.tagID == uuid })
82 | } else {
83 | // Reset tag
84 | scriptHandler.scripts = scriptHandler.savedScripts
85 | }
86 | }
87 |
88 | func hideModal() {
89 | resetForm()
90 | modalHandler.hideModal()
91 | }
92 |
93 | func resetForm() {
94 | tagHandler.editMode = false
95 | tagHandler.editTag = EmptyTag
96 | tagHandler.editColor = AppColor.Primary
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # Script Manager (macOS)
6 |
7 | An easy and comfortable macOS **menu bar-tool**, to organize and use your own terminal-scripts.
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | #
20 |
21 | ## Usage
22 |
23 | Script Manager is a menu bar-tool to organize and simplify running a custom terminal-script:
24 | - Add a new script and test it before saving (persistent storage)
25 | - Full customizable settings to use your personal configurations
26 | - Error-logging with custom directory
27 | - Local notifications when script finished
28 | - Global shortcuts for faster execution of scripts
29 | - ...
30 |
31 | ### New features added in version 4.0 (v1.0.3)
32 | - New animations and brand new design
33 | - Highlighted text editor for easier creation of scripts
34 | - Now possible to interrupt running scripts
35 | - More possibilities with new input function for scripts
36 | - Presets for scripts and tags added
37 | - New searchbar for filtering your saved scripts
38 | - Better time analysis of your scripts
39 |
40 | ### New features added in version 3.0 (v1.0.2)
41 | - Fixed dismissing sheet to use ScriptManager with macOS Sonoma
42 | - Added dynamic time calculation to monitor your progress
43 | - Parallel execution of multiple scripts has been enabled
44 |
45 | ### New features added in version 2.0 (v1.0.1)
46 | - Output window for running script
47 | - Change your main color from settings
48 | - Add Tags to organize your scripts
49 | - New UI-Design
50 | - Keyboard-shortcuts for scripts
51 | - Toggle to run tool by startup
52 |
53 |
54 |
55 | ## Start
56 |
57 |
58 | ## Script
59 |
60 |
61 | ## Settings
62 |
63 |
64 | ## Output window
65 |
66 |
67 | ## Setup via Homebrew
68 | To install the ScriptManager with Homebrew, you have to run the following command in your terminal:
69 |
70 | `brew install danielfiller30/tap/scriptmanager`
71 |
72 | Or `brew tap danielfiller30/tap` and then `brew install scriptmanager`.
73 |
74 | To open the app, you have to trust the application in the system settings:
75 | - Open settings on your Mac
76 | - Navigate to **Privacy & Security**
77 | - Under security, press allow to open the ScriptManager
78 |
79 | ## Setup via Xcode
80 | To install and use your personal Script Manager via Xcode, you have to follow this steps:
81 | - Clone this project to your local space
82 | - Open the project in Xcode
83 | - Run a build
84 | - Copy the resulting program-file to your programs folder
85 | > Xcode > Product > Show Build Folder in Finder
86 |
87 |
--------------------------------------------------------------------------------
/ScriptManager/ScriptManagerApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScriptManagerApp.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 03.02.23.
6 | //
7 |
8 | import KeyboardShortcuts
9 | import Resolver
10 | import SwiftUI
11 |
12 | @main
13 | struct ScriptManagerApp: App {
14 | @Injected private var storageHandler: StorageHandlerProtocol
15 |
16 | let window = NSWindow()
17 |
18 | @State var hideWelcomeScreen: Bool = true
19 | @StateObject private var appState = AppState()
20 |
21 | init() {
22 | // Open welcome-screen on first launch
23 | if storageHandler.firstLaunch {
24 | openWindow()
25 | }
26 | }
27 |
28 | func openWindow() {
29 | let contentView = WelcomeView(close: { closeWindow() }, hideWelcomeScreen: $hideWelcomeScreen)
30 | window.contentViewController = NSHostingController(rootView: contentView)
31 | window.styleMask = [.closable, .titled]
32 | window.center()
33 | window.orderFrontRegardless()
34 | window.makeKeyAndOrderFront(nil)
35 | }
36 |
37 | func closeWindow() {
38 | if hideWelcomeScreen {
39 | storageHandler.setFirstLaunchToFalse()
40 | }
41 |
42 | window.close()
43 | }
44 |
45 | var body: some Scene {
46 | MenuBarExtra("", image: "StatusBarIcon") {
47 | MainView()
48 | }
49 | .menuBarExtraStyle(.window)
50 | }
51 | }
52 |
53 | @MainActor
54 | final class AppState: ObservableObject {
55 | @Injected private var storageHandler: StorageHandlerProtocol
56 | @Injected private var scriptHandler: ScriptHandlerProtocol
57 |
58 | private var savedShortcuts: [Shortcut] = []
59 |
60 | init() {
61 | // Initialize keyboard shortcuts
62 | KeyboardShortcuts.onKeyUp(for: .runScript1) {
63 | debugPrint("Run Script 1")
64 | self.runScript(index: 0)
65 | }
66 |
67 | KeyboardShortcuts.onKeyUp(for: .runScript2) {
68 | debugPrint("Run Script 2")
69 | self.runScript(index: 1)
70 | }
71 |
72 | KeyboardShortcuts.onKeyUp(for: .runScript3) {
73 | debugPrint("Run Script 3")
74 | self.runScript(index: 2)
75 | }
76 |
77 | KeyboardShortcuts.onKeyUp(for: .runScript4) {
78 | debugPrint("Run Script 4")
79 | self.runScript(index: 3)
80 | }
81 |
82 | KeyboardShortcuts.onKeyUp(for: .runScript5) {
83 | debugPrint("Run Script 5")
84 | self.runScript(index: 4)
85 | }
86 | }
87 |
88 | private func runScript(index: Int) {
89 | savedShortcuts = storageHandler.settings.shortcuts
90 |
91 | guard !savedShortcuts.isEmpty else { return }
92 | let id = savedShortcuts[index].scriptId
93 |
94 | if id != EmptyScript.id {
95 | if let script = storageHandler.scripts.first(where: { $0.id == id }) {
96 | NotificationHandler.sendStartNotification(name: script.name)
97 |
98 | Task {
99 | await scriptHandler.runScript(script, test: false)
100 | }
101 | }
102 | }
103 | }
104 |
105 | /* // Reset function for debugging and testing
106 | private func resetShortcuts() {
107 | KeyboardShortcuts.reset(.runScript1)
108 | KeyboardShortcuts.reset(.runScript2)
109 | KeyboardShortcuts.reset(.runScript3)
110 | KeyboardShortcuts.reset(.runScript4)
111 | KeyboardShortcuts.reset(.runScript5)
112 | }*/
113 | }
114 |
--------------------------------------------------------------------------------
/ScriptManager.xcodeproj/xcshareddata/xcschemes/ScriptManager.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
32 |
33 |
37 |
38 |
39 |
40 |
42 |
48 |
49 |
50 |
51 |
52 |
64 |
66 |
72 |
73 |
74 |
75 |
81 |
83 |
89 |
90 |
91 |
92 |
94 |
95 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/Tests/Extensions/IntervalTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IntervalTests.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 29.07.24.
6 | //
7 |
8 | @testable import ScriptManager
9 |
10 | import Testing
11 | import Foundation
12 |
13 | struct IntervalTests {
14 |
15 | struct DailyJob {
16 | let calendar = Calendar.current
17 |
18 | @Test("Run daily job") func testDailyTrue() async throws {
19 | let interval: Interval = .DAILY
20 |
21 | var checkDate = calendar.date(byAdding: .day, value: -1, to: Date.now)
22 | var expectedResult = interval.checkDiff(lastExecuted: checkDate!)
23 | #expect(expectedResult == true)
24 |
25 | checkDate = calendar.date(byAdding: .day, value: -20, to: Date.now)
26 | expectedResult = interval.checkDiff(lastExecuted: checkDate!)
27 | #expect(expectedResult == true)
28 | }
29 |
30 | @Test("Don't run daily job") func testDailyFalse() async throws {
31 | let interval: Interval = .DAILY
32 |
33 | var checkDate = calendar.date(byAdding: .day, value: 1, to: Date.now)
34 | var expectedResult = interval.checkDiff(lastExecuted: checkDate!)
35 | #expect(expectedResult == false)
36 |
37 | checkDate = calendar.date(byAdding: .day, value: 20, to: Date.now)
38 | expectedResult = interval.checkDiff(lastExecuted: checkDate!)
39 | #expect(expectedResult == false)
40 | }
41 | }
42 |
43 | struct WeeklyJob {
44 | let calendar = Calendar.current
45 |
46 | @Test("Run weekly job") func testWeeklyTrue() async throws {
47 | let interval: Interval = .WEEKLY
48 |
49 | var checkDate = calendar.date(byAdding: .weekOfMonth, value: -1, to: Date.now)
50 | var expectedResult = interval.checkDiff(lastExecuted: checkDate!)
51 | #expect(expectedResult == true)
52 |
53 | checkDate = calendar.date(byAdding: .weekOfMonth, value: -20, to: Date.now)
54 | expectedResult = interval.checkDiff(lastExecuted: checkDate!)
55 | #expect(expectedResult == true)
56 | }
57 |
58 | @Test("Don't run weekly job") func testWeeklyFalse() async throws {
59 | let interval: Interval = .WEEKLY
60 |
61 | var checkDate = calendar.date(byAdding: .weekOfMonth, value: 1, to: Date.now)
62 | var expectedResult = interval.checkDiff(lastExecuted: checkDate!)
63 | #expect(expectedResult == false)
64 |
65 | checkDate = calendar.date(byAdding: .weekOfMonth, value: 20, to: Date.now)
66 | expectedResult = interval.checkDiff(lastExecuted: checkDate!)
67 | #expect(expectedResult == false)
68 | }
69 | }
70 |
71 | struct MonthlyJob {
72 | let calendar = Calendar.current
73 |
74 | @Test("Run monthly job") func testMonthlyTrue() async throws {
75 | let interval: Interval = .MONTHLY
76 |
77 | var checkDate = calendar.date(byAdding: .month, value: -1, to: Date.now)
78 | var expectedResult = interval.checkDiff(lastExecuted: checkDate!)
79 | #expect(expectedResult == true)
80 |
81 | checkDate = calendar.date(byAdding: .month, value: -20, to: Date.now)
82 | expectedResult = interval.checkDiff(lastExecuted: checkDate!)
83 | #expect(expectedResult == true)
84 | }
85 |
86 | @Test("Don't run monthly job") func testMonthlyFalse() async throws {
87 | let interval: Interval = .MONTHLY
88 |
89 | var checkDate = calendar.date(byAdding: .month, value: 1, to: Date.now)
90 | var expectedResult = interval.checkDiff(lastExecuted: checkDate!)
91 | #expect(expectedResult == false)
92 |
93 | checkDate = calendar.date(byAdding: .month, value: 20, to: Date.now)
94 | expectedResult = interval.checkDiff(lastExecuted: checkDate!)
95 | #expect(expectedResult == false)
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/ScriptManager/Views/HeaderView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HeaderView.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 03.02.23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct HeaderView: View {
11 | @State private var vm = HeaderViewModel()
12 |
13 | var body: some View {
14 | HStack(alignment: .center) {
15 | Image("Logo")
16 | .resizable()
17 | .scaledToFit()
18 | .frame(height: 20.0)
19 | .padding(.trailing, Spacing.m)
20 |
21 | Text("Script Manager")
22 | .fontWeight(.bold)
23 |
24 | Text("v4")
25 | .fontWeight(.light)
26 |
27 | Spacer()
28 |
29 | if vm.selectedTag != nil {
30 | Button {
31 | vm.showDeleteTagAlert()
32 | } label: {
33 | HStack {
34 | Image(systemName: "trash")
35 | .resizable()
36 | .frame(width: IconSize.m, height: IconSize.m)
37 | }
38 | .frame(width: IconSize.l, height: IconSize.l)
39 | .padding(Spacing.m)
40 | .background(.ultraThinMaterial)
41 | .clipShape(Circle())
42 | .overlay(
43 | Circle()
44 | .stroke(vm.getTagColor(), lineWidth: 1)
45 | )
46 | .shadow(radius: 3, x: 1, y: 2)
47 | .help("hint-remove-tag")
48 | }
49 | .buttonStyle(.plain)
50 | .padding(.trailing, Spacing.m)
51 |
52 | Button {
53 | vm.openEditTag()
54 | } label: {
55 | HStack {
56 | Image(systemName: "pencil")
57 | .resizable()
58 | .frame(width: IconSize.m, height: IconSize.m)
59 | }
60 | .frame(width: IconSize.l, height: IconSize.l)
61 | .padding(Spacing.m)
62 | .background(.ultraThinMaterial)
63 | .clipShape(Circle())
64 | .overlay(
65 | Circle()
66 | .stroke(vm.getTagColor(), lineWidth: 1)
67 | )
68 | .shadow(radius: 3, x: 1, y: 2)
69 | .help("hint-remove-tag")
70 | }
71 | .buttonStyle(.plain)
72 | .padding(.trailing, Spacing.m)
73 | }
74 |
75 | Button {
76 | vm.modalHandler.showModal(.ADD)
77 | } label: {
78 | HStack {
79 | Image(systemName: "plus")
80 | .resizable()
81 | .frame(width: IconSize.m, height: IconSize.m)
82 | }
83 | .frame(width: IconSize.l, height: IconSize.l)
84 | .padding(Spacing.m)
85 | .background(.ultraThinMaterial)
86 | .clipShape(Circle())
87 | .shadow(radius: 3, x: 1, y: 2)
88 | }
89 | .buttonStyle(.plain)
90 | .padding(.trailing, Spacing.m)
91 |
92 | Button {
93 | vm.modalHandler.showModal(.SETTINGS)
94 | } label: {
95 | Image(systemName: "gear")
96 | .resizable()
97 | .frame(width: IconSize.l, height: IconSize.l)
98 | .padding(Spacing.m)
99 | .background(.ultraThinMaterial)
100 | .clipShape(Circle())
101 | .shadow(radius: 3, x: 1, y: 2)
102 | }
103 | .buttonStyle(.plain)
104 | }
105 | .padding(.vertical, 15)
106 | .padding(.horizontal, Spacing.xl)
107 | }
108 | }
109 |
110 | struct HeaderView_Previews: PreviewProvider {
111 | static var previews: some View {
112 | HeaderView()
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/ScriptManager/Views/Modals/TagModalView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddTagView.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 22.03.23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct TagModalView: View {
11 | @State private var vm = TagViewModel()
12 | let colors: [Color] = [.brown, .cyan, .indigo, .mint, .pink, .green, .blue, .orange, .purple, .red]
13 |
14 | var body: some View {
15 | VStack(alignment: .leading) {
16 | HStack(alignment: .center) {
17 | Text("name-add-tag")
18 | .font(.subheadline)
19 |
20 | Spacer()
21 |
22 | TextField("", text: $vm.tagHandler.editTag.name)
23 | .frame(maxWidth: 155)
24 | .cornerRadius(8)
25 | .textFieldStyle(.roundedBorder)
26 | }
27 | .padding(.bottom, Spacing.l)
28 |
29 | HStack(alignment: .center) {
30 | Text("color-add-tag")
31 | .font(.subheadline)
32 |
33 | Spacer()
34 |
35 | Picker("", selection: $vm.tagHandler.editColor) {
36 | ForEach(colors, id: \.self) { color in
37 | Text(color.description.capitalized)
38 | .foregroundStyle(color)
39 | }
40 | }
41 | .pickerStyle(.menu)
42 | .frame(width: 100)
43 |
44 | ColorPicker("", selection: $vm.tagHandler.editColor)
45 | }
46 | .padding(.bottom, Spacing.l)
47 |
48 | Divider()
49 | .padding(.vertical, Spacing.l)
50 |
51 | Text("presets")
52 | .font(.subheadline)
53 |
54 | ScrollView(.horizontal) {
55 | HStack {
56 | ForEach(TagPresets, id: \.self) { preset in
57 | Button {
58 | vm.tagHandler.editTag.name = preset.title
59 | vm.tagHandler.editColor = preset.color
60 | } label: {
61 | HStack(alignment: .center) {
62 | Image(systemName: preset.icon)
63 |
64 | Text(preset.title)
65 | .font(.caption)
66 | }
67 | .padding(.horizontal)
68 | .padding(.vertical, 10)
69 | .background(.ultraThickMaterial)
70 | .clipShape(RoundedRectangle(cornerRadius: 10))
71 | }
72 | .buttonStyle(.plain)
73 | }
74 | }
75 | }
76 |
77 | Divider()
78 | .padding(.vertical, Spacing.l)
79 |
80 | HStack(alignment: .center, spacing: Spacing.xl) {
81 | // Cancel
82 | CustomButtonView(
83 | onClick: { vm.hideModal() },
84 | label: "cancel",
85 | color: AnyShapeStyle(.ultraThickMaterial),
86 | outlined: false,
87 | disabled: false
88 | )
89 |
90 | // Save tag
91 | CustomButtonView(
92 | onClick: {
93 | if vm.editMode {
94 | vm.saveChangedTag()
95 | } else {
96 | vm.saveTag()
97 | }
98 | },
99 | label: vm.editMode ? "save-changed-tag" : "save-tag",
100 | color: AnyShapeStyle(AppColor.Primary),
101 | outlined: false,
102 | disabled: vm.tagHandler.editTag.name.isEmpty
103 | )
104 | }
105 | }
106 | }
107 | }
108 |
109 | struct TagModalView_Previews: PreviewProvider {
110 | static var previews: some View {
111 | TagModalView()
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/ScriptManager/Views/Modals/AddModalView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddModalView.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 05.07.24.
6 | //
7 |
8 | import Resolver
9 | import SwiftUI
10 |
11 | struct AddModalView: View {
12 | @State private var vm = ModalViewModel()
13 |
14 | var body: some View {
15 | HStack {
16 | Spacer()
17 |
18 | VStack(alignment: .center) {
19 | if #available(macOS 15.0, *) {
20 | Image(systemName: "doc.badge.plus")
21 | .resizable()
22 | .scaledToFit()
23 | .frame(height: 30)
24 | .padding(.bottom, Spacing.l)
25 | .symbolEffect(.bounce, options: .nonRepeating)
26 | } else {
27 | // Fallback on earlier versions
28 | Image(systemName: "doc.badge.plus")
29 | .resizable()
30 | .scaledToFit()
31 | .frame(height: 30)
32 | .padding(.bottom, Spacing.l)
33 | }
34 |
35 | Button {
36 | vm.modalHandler.hideModal()
37 | vm.modalHandler.showModal(.ADD_SCRIPT)
38 | } label: {
39 | HStack(alignment: .center) {
40 | Spacer()
41 |
42 | Text("add-new-script")
43 | .bold()
44 |
45 | Spacer()
46 | }
47 | .frame(width: 250)
48 | .padding()
49 | .background(.ultraThickMaterial)
50 | .cornerRadius(8)
51 | .shadow(radius: 3, x: 1, y: 2)
52 | }
53 | .buttonStyle(.plain)
54 |
55 | Text("add-script-info")
56 | .font(.footnote)
57 | .foregroundStyle(AppColor.Creme)
58 | .padding(.bottom, Spacing.xl)
59 | .padding(.top, Spacing.m)
60 | .padding(.horizontal, Spacing.m)
61 | .multilineTextAlignment(.center)
62 |
63 | if #available(macOS 15.0, *) {
64 | Image(systemName: "tag")
65 | .resizable()
66 | .scaledToFit()
67 | .frame(height: 30)
68 | .padding(.bottom, Spacing.l)
69 | .symbolEffect(.bounce, options: .nonRepeating)
70 | } else {
71 | // Fallback on earlier versions
72 | Image(systemName: "tag")
73 | .resizable()
74 | .scaledToFit()
75 | .frame(height: 30)
76 | .padding(.bottom, Spacing.l)
77 | }
78 |
79 | Button {
80 | vm.modalHandler.hideModal()
81 | vm.modalHandler.showModal(.ADD_TAG)
82 | } label: {
83 | HStack(alignment: .center) {
84 | Spacer()
85 |
86 | Text("add-new-tag")
87 | .bold()
88 |
89 | Spacer()
90 | }
91 | .frame(width: 250)
92 | .padding()
93 | .background(.ultraThickMaterial)
94 | .cornerRadius(8)
95 | .shadow(radius: 3, x: 1, y: 2)
96 | }
97 | .buttonStyle(.plain)
98 |
99 | Text("add-tag-info")
100 | .font(.footnote)
101 | .foregroundStyle(AppColor.Creme)
102 | .padding(.bottom, Spacing.xl)
103 | .padding(.top, Spacing.m)
104 | .padding(.horizontal, Spacing.m)
105 | .multilineTextAlignment(.center)
106 | }
107 |
108 | Spacer()
109 | }
110 | }
111 | }
112 |
113 | struct AddModalView_Previews: PreviewProvider {
114 | static var previews: some View {
115 | AddModalView()
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/ScriptManager/Provider/BackupProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BackupProvider.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 20.07.24.
6 | //
7 |
8 | import Resolver
9 | import SwiftUI
10 |
11 | class BackupProvider {
12 | @LazyInjected private var hintHandler: HintHandlerProtocol
13 | @LazyInjected private var modalHandler: ModalHandlerProtocol
14 | @LazyInjected private var storageHandler: StorageHandlerProtocol
15 | @LazyInjected private var alertHandler: AlertHandlerProtocol
16 |
17 | private let jsonProvider = JSONProvider()
18 |
19 | /// Export userdata as json to file
20 | func exportUserdata() {
21 | let folderChooserPoint = CGPoint(x: 0, y: 0)
22 | let folderChooserSize = CGSize(width: 500, height: 600)
23 | let folderChooserRectangle = CGRect(origin: folderChooserPoint, size: folderChooserSize)
24 | let folderPicker = NSOpenPanel(contentRect: folderChooserRectangle, styleMask: .utilityWindow, backing: .buffered, defer: true)
25 |
26 | folderPicker.canChooseDirectories = true
27 | folderPicker.canChooseFiles = false
28 | folderPicker.allowsMultipleSelection = false
29 | folderPicker.canDownloadUbiquitousContents = false
30 | folderPicker.canResolveUbiquitousConflicts = true
31 |
32 | folderPicker.begin { response in
33 | if response == .OK {
34 | let pickedFolder = folderPicker.url
35 | if let pickedFolder {
36 | let fileName = pickedFolder.appendingPathComponent("ScriptManager_\(Date.now.toDateString()).json")
37 | let userdataJSON = self.jsonProvider.convertToJson()
38 |
39 | if let userdataJSON {
40 | do {
41 | try userdataJSON.write(to: fileName, atomically: true, encoding: String.Encoding.utf8)
42 | self.hintHandler.showHint(String(localized: "Data exported"), type: .success)
43 | } catch {
44 | self.hintHandler.showHint(String(localized: "Oops, something went wrong"), type: .error)
45 | debugPrint(error)
46 | }
47 | } else {
48 | self.hintHandler.showHint(String(localized: "Oops, something went wrong"), type: .error)
49 | }
50 | }
51 | }
52 | }
53 | }
54 |
55 | /// Import userdata as json file and replace values of storage
56 | public func importUserdata() {
57 | let fileChooserPoint = CGPoint(x: 0, y: 0)
58 | let fileChooserSize = CGSize(width: 500, height: 600)
59 | let fileChooserRectangle = CGRect(origin: fileChooserPoint, size: fileChooserSize)
60 | let filePicker = NSOpenPanel(contentRect: fileChooserRectangle, styleMask: .utilityWindow, backing: .buffered, defer: true)
61 |
62 | filePicker.canChooseDirectories = false
63 | filePicker.canChooseFiles = true
64 | filePicker.allowsMultipleSelection = false
65 | filePicker.canDownloadUbiquitousContents = false
66 | filePicker.canResolveUbiquitousConflicts = true
67 |
68 | filePicker.begin { response in
69 | if response == .OK {
70 | let pickedFile = filePicker.url
71 | if let pickedFile {
72 | do {
73 | let data = try Data(contentsOf: pickedFile)
74 | let userdataObject = self.jsonProvider.decodeToObject(data: data)
75 | guard let userdataObject else {
76 | self.hintHandler.showHint(String(localized: "Oops, something went wrong"), type: .error)
77 | return
78 | }
79 |
80 | self.storageHandler.scripts = userdataObject.scripts
81 | self.storageHandler.times = userdataObject.times
82 | self.storageHandler.tags = userdataObject.tags
83 | self.storageHandler.settings = userdataObject.settings
84 |
85 | self.modalHandler.hideModal()
86 | self.alertHandler.showAlert(
87 | title: String(localized: "import-successfull"),
88 | message: String(localized: "import-restart"),
89 | btnTitle: String(localized: "restart"),
90 | cancelVisible: false,
91 | action: { NSApp.terminate(self) }
92 | )
93 | } catch {
94 | self.hintHandler.showHint(String(localized: "Oops, something went wrong"), type: .error)
95 | debugPrint(error)
96 | }
97 | }
98 | }
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/ScriptManager/Views/Scripts/ScriptRow/ScriptRowActions/ScriptRunButtonView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScriptRunButtonView.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 10.02.23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ScriptRunButtonView: View {
11 | @State private var vm = ScriptViewModel()
12 |
13 | var script: Script
14 |
15 | @State var showRunPopover: Bool = false
16 |
17 | var body: some View {
18 | if (vm.scriptHandler.runningScript.contains(where: { $0.id == script.id })) {
19 | if let time = vm.scriptHandler.scripts.first(where: { $0.id == script.id })?.time?.remainingTime {
20 | Text(time)
21 | .font(.caption2)
22 | .padding(Spacing.l)
23 | .cornerRadius(10.0)
24 | } else {
25 | ProgressView()
26 | .frame(width: IconSize.s, height: IconSize.s)
27 | .scaleEffect(0.5)
28 | .padding(Spacing.l)
29 | .clipShape(Circle())
30 | }
31 | } else {
32 | Button {
33 | // Empty - LongPress on Image; Button for animation
34 | } label: {
35 | Image(systemName: "play")
36 | .resizable()
37 | .frame(width: IconSize.s, height: IconSize.s)
38 | .padding(Spacing.l)
39 | .clipShape(Circle())
40 | .onTapGesture {
41 | vm.scriptHandler.runningScript.append(script)
42 | vm.runScript(showOutput: false, scriptId: script.id)
43 | }
44 | .onLongPressGesture(minimumDuration: 1.0) {
45 | showRunPopover.toggle()
46 | }
47 | }
48 | .buttonStyle(.plain)
49 | .popover(isPresented: $showRunPopover, arrowEdge: .bottom) {
50 | VStack(alignment: .center, spacing: Spacing.l) {
51 | Button {
52 | showRunPopover.toggle()
53 | vm.scriptHandler.runningScript.append(script)
54 | vm.runScript(showOutput: false, scriptId: script.id)
55 | } label: {
56 | HStack(alignment: .center) {
57 | Spacer()
58 |
59 | Text("script-run")
60 | .font(.caption)
61 |
62 | Spacer()
63 |
64 | Image(systemName: "play")
65 | .resizable()
66 | .foregroundStyle(AppColor.Primary)
67 | .frame(width: IconSize.s, height: IconSize.s)
68 | }
69 | .frame(width: 150)
70 | .padding(.horizontal, Spacing.l)
71 | .padding(.vertical, Spacing.l)
72 | .background(.ultraThinMaterial)
73 | .cornerRadius(8)
74 | .shadow(radius: 3, x: 1, y: 2)
75 | }
76 | .buttonStyle(.plain)
77 |
78 | Button {
79 | showRunPopover.toggle()
80 | vm.scriptHandler.runningScript.append(script)
81 | vm.runScript(showOutput: true, scriptId: script.id)
82 | } label: {
83 | HStack(alignment: .center) {
84 | Spacer()
85 |
86 | Text("script-run-output")
87 | .font(.caption)
88 |
89 | Spacer()
90 |
91 | Image(systemName: "play.display")
92 | .resizable()
93 | .foregroundStyle(AppColor.Secondary)
94 | .frame(width: IconSize.s, height: IconSize.s)
95 | }
96 | .frame(width: 150)
97 | .padding(.horizontal, Spacing.l)
98 | .padding(.vertical, Spacing.l)
99 | .background(.ultraThinMaterial)
100 | .cornerRadius(8)
101 | .shadow(radius: 3, x: 1, y: 2)
102 | }
103 | // .disabled(vm.scriptHandler.isRunningWithOutput)
104 | .buttonStyle(.plain)
105 | }
106 | .padding(.all, Spacing.l)
107 | }
108 | }
109 | }
110 | }
111 |
112 | struct ScriptRunButtonView_Previews: PreviewProvider {
113 | static var previews: some View {
114 | ScriptRunButtonView(script: DefaultScript)
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/ScriptManager/Views/Modals/SettingsModalView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsView.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 06.02.23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SettingsModalView: View {
11 | @State private var toggleShell = false
12 | @State private var toggleShortcuts = false
13 | @State private var toggleBackup = false
14 | @State private var toggleLogging = false
15 |
16 | @State private var vm = SettingsViewModel()
17 |
18 | var body: some View {
19 | VStack(alignment: .center) {
20 | ScrollView {
21 | Group {
22 | SettingsNotificationsView(vm: $vm)
23 |
24 | Divider()
25 |
26 | SettingsAutostartView()
27 | }
28 |
29 | Divider()
30 |
31 | GroupView(
32 | toggleVar: $toggleShell,
33 | toggle: { toggleShell.toggle() },
34 | label: "settings-shell",
35 | info: nil,
36 | padding: Spacing.zero,
37 | animation: false
38 | ) {
39 | SettingsShellView(vm: $vm)
40 |
41 | SettingsUnicodeView(vm: $vm)
42 | }
43 |
44 | Divider()
45 |
46 | GroupView(
47 | toggleVar: $toggleShortcuts,
48 | toggle: { toggleShortcuts.toggle() },
49 | label: "settings-shortcut",
50 | info: nil,
51 | padding: Spacing.zero,
52 | animation: false
53 | ) {
54 | SettingsShortcutView(vm: $vm)
55 | }
56 |
57 | Divider()
58 |
59 | GroupView(
60 | toggleVar: $toggleLogging,
61 | toggle: { toggleLogging.toggle() },
62 | label: "logging",
63 | info: nil,
64 | padding: Spacing.zero,
65 | animation: false
66 | ) {
67 | SettingsLoggingView(vm: $vm)
68 | }
69 |
70 | Divider()
71 |
72 | GroupView(
73 | toggleVar: $toggleBackup,
74 | toggle: { toggleBackup.toggle() },
75 | label: "backup",
76 | info: nil,
77 | padding: Spacing.zero,
78 | animation: false
79 | ) {
80 | SettingsBackupView()
81 | }
82 | }
83 |
84 | Divider()
85 |
86 | Group {
87 | // Save settings
88 | CustomButtonView(
89 | onClick: { vm.saveSettings() },
90 | label: "settings-save",
91 | color: AnyShapeStyle(AppColor.Primary),
92 | outlined: false,
93 | disabled: vm.tempSettings.shell.path.isEmpty
94 | || vm.tempSettings.unicode.isEmpty
95 | || (vm.tempSettings.logs && vm.tempSettings.pathLogs.isEmpty)
96 | )
97 | .padding(.bottom, Spacing.m)
98 | .padding(.top, Spacing.l)
99 |
100 | // Cancel
101 | CustomButtonView(
102 | onClick: { vm.modalHandler.hideModal() },
103 | label: "cancel",
104 | color: AnyShapeStyle(.ultraThickMaterial),
105 | outlined: true,
106 | disabled: false
107 | )
108 |
109 | HStack {
110 | CustomButtonView(
111 | onClick: {
112 | vm.modalHandler.hideModal()
113 | vm.modalHandler.showModal(.INFO)
114 | },
115 | label: "info-title",
116 | color: AnyShapeStyle(.ultraThickMaterial),
117 | outlined: true,
118 | disabled: false
119 | )
120 | .padding(.bottom, Spacing.xl)
121 |
122 | CustomButtonView(
123 | onClick: {
124 | vm.modalHandler.hideModal()
125 | vm.showCloseAlert()
126 | },
127 | label: "close-app-title",
128 | color: AnyShapeStyle(.ultraThickMaterial),
129 | outlined: true,
130 | disabled: false
131 | )
132 | .padding(.bottom, Spacing.xl)
133 | }
134 | }
135 | .padding(.horizontal, Spacing.xl)
136 |
137 | }
138 | }
139 | }
140 |
141 | struct SettingsModalView_Previews: PreviewProvider {
142 | static var previews: some View {
143 | SettingsModalView()
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/ScriptManager/Views/Scripts/ScriptRow/ScriptDetailsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScriptDetailsView.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 05.02.23.
6 | //
7 |
8 | import Resolver
9 | import SwiftUI
10 | import AlertToast
11 |
12 | struct ScriptDetailsView: View {
13 | @State private var vm = ScriptViewModel()
14 |
15 | @Binding var showAddScriptModal: Bool
16 |
17 | var script: Script
18 |
19 | @State private var showToast = false
20 |
21 | var body: some View {
22 | VStack(alignment: .leading) {
23 | HStack(alignment: .center) {
24 | Text("path")
25 | .font(.caption)
26 | .fontWeight(.bold)
27 | .frame(width: 120, alignment: .leading)
28 |
29 | Spacer()
30 |
31 | Text(script.command)
32 | .font(.caption)
33 | .lineLimit(1)
34 | .onTapGesture() {
35 | // Copy path
36 | let pasteboard = NSPasteboard.general
37 | pasteboard.clearContents()
38 | pasteboard.setString(script.command, forType: .string)
39 |
40 | // Show 'copied' toast
41 | showToast.toggle()
42 | }
43 | .help(script.command)
44 | }
45 | .padding(.bottom, Spacing.l)
46 | .toast(isPresenting: $showToast, duration: 1, tapToDismiss: true) {
47 | AlertToast(type: .regular, title: String(localized: "hint-copied"))
48 | }
49 |
50 | HStack(alignment: .center) {
51 | Text("shortcut-title")
52 | .font(.caption)
53 | .fontWeight(.bold)
54 | .frame(width: 120, alignment: .leading)
55 |
56 | Spacer()
57 |
58 | if let shortcut = vm.getMatchingShortcut(script.id) {
59 | ShortcutView(shortcut: shortcut)
60 | } else {
61 | Text("-")
62 | .font(.caption)
63 | }
64 | }
65 | .padding(.bottom, Spacing.l)
66 |
67 | HStack(alignment: .center) {
68 | Text("last-run")
69 | .font(.caption)
70 | .fontWeight(.bold)
71 | .frame(width: 120, alignment: .leading)
72 |
73 | Spacer()
74 |
75 | if let date = script.lastRun {
76 | Text(date.toFormattedDate())
77 | .font(.caption)
78 | } else {
79 | Text("-")
80 | .font(.caption)
81 | }
82 | }
83 | .padding(.bottom, Spacing.l)
84 |
85 | Divider()
86 | .foregroundStyle(.white)
87 | .padding(.bottom, Spacing.m)
88 |
89 | HStack(alignment: .center) {
90 | // Open logs
91 | ScriptDetailButtonView(
92 | onClick: { vm.openLogs() },
93 | icon: "folder",
94 | disabled: false,
95 | help: "button-logs"
96 | )
97 |
98 | Spacer()
99 |
100 | // Open monitor
101 | ScriptDetailButtonView(
102 | onClick: {
103 | vm.openOutputWindow(script: script)
104 | },
105 | icon: "play.tv",
106 | disabled: false,
107 | help: "button-monitor"
108 | )
109 |
110 | // Interrupt script
111 | ScriptDetailButtonView(
112 | onClick: {
113 | debugPrint("Interrupt script")
114 | vm.scriptHandler.interruptRunningProcess(scriptId: script.id)
115 | },
116 | icon: "stop.fill",
117 | disabled: vm.scriptHandler.runningScript.count == 0 || !vm.scriptHandler.runningScript.contains(where: { $0.id == script.id }),
118 | help: "button-interrupt"
119 | )
120 |
121 | // Edit script
122 | ScriptDetailButtonView(
123 | onClick: {
124 | vm.openEdit(script: script)
125 | },
126 | icon: "pencil",
127 | disabled: vm.scriptHandler.runningScript.contains(where: { $0.id == script.id }),
128 | help: "button-edit"
129 | )
130 |
131 | // Delete script
132 | ScriptDeleteButtonView(
133 | scriptId: script.id,
134 | disabled: vm.scriptHandler.runningScript.contains(where: { $0.id == script.id })
135 | )
136 | }
137 | }
138 | }
139 | }
140 |
141 | struct ScriptDetailsView_Previews: PreviewProvider {
142 | static var previews: some View {
143 | ScriptDetailsView(showAddScriptModal: .constant(false), script: DefaultScript)
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/ScriptManager/Views/Scripts/OutputWindowView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OutputWindowView.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 03.04.23.
6 | //
7 |
8 | import Resolver
9 | import SwiftUI
10 | import AlertToast
11 |
12 | struct OutputWindowView: View {
13 | @Injected private var scriptHandler: ScriptHandlerProtocol
14 |
15 | @State private var showToast = false
16 |
17 | var script: Script
18 | var window: NSWindow
19 |
20 | var body: some View {
21 | VStack(alignment: .leading) {
22 | HStack {
23 | Text(script.name)
24 | .font(.title2)
25 |
26 | Spacer()
27 |
28 | if let time = scriptHandler.scripts.first(where: { $0.id == script.id })?.time?.remainingTime {
29 | Text(time)
30 | .font(.caption2)
31 | .padding(Spacing.l)
32 | .cornerRadius(10.0)
33 | }
34 | }
35 |
36 | Divider()
37 | .padding(.vertical, Spacing.l)
38 |
39 | Text("command")
40 | .font(.headline)
41 |
42 | HStack {
43 | Text(script.command)
44 | .font(.caption)
45 |
46 | Spacer()
47 | }
48 | .padding()
49 | .background(.ultraThickMaterial)
50 | .clipShape(RoundedRectangle(cornerRadius: 10))
51 | .shadow(radius: 3, x: 1, y: 2)
52 | .padding(.bottom, Spacing.xxl)
53 |
54 | Text("output")
55 | .font(.headline)
56 |
57 | ScrollView {
58 | HStack {
59 | VStack(alignment: .leading) {
60 | Text(scriptHandler.scripts.first(where: { $0.id == script.id })?.output ?? "")
61 | .multilineTextAlignment(.leading)
62 | .padding(.bottom, Spacing.l)
63 | }
64 |
65 | Spacer()
66 | }
67 | }
68 | .padding()
69 | .background(.ultraThickMaterial)
70 | .clipShape(RoundedRectangle(cornerRadius: 10))
71 | .shadow(radius: 3, x: 1, y: 2)
72 | .padding(.bottom, Spacing.xxl)
73 |
74 | Text("errors")
75 | .font(.headline)
76 |
77 | ScrollView {
78 | HStack {
79 | VStack(alignment: .leading) {
80 | Text(scriptHandler.scripts.first(where: { $0.id == script.id })?.error ?? "")
81 | .foregroundStyle(AppColor.Danger)
82 | .multilineTextAlignment(.leading)
83 | .padding(.bottom, Spacing.l)
84 | }
85 |
86 | Spacer()
87 | }
88 | }
89 | .padding()
90 | .background(.ultraThickMaterial)
91 | .clipShape(RoundedRectangle(cornerRadius: 10))
92 | .shadow(radius: 3, x: 1, y: 2)
93 | .padding(.bottom, Spacing.xxl)
94 |
95 | HStack(spacing: Spacing.l) {
96 | Spacer()
97 |
98 | Button {
99 | // Copy path
100 | let pasteboard = NSPasteboard.general
101 | pasteboard.clearContents()
102 | pasteboard.setString(scriptHandler.scripts.first(where: { $0.id == script.id })?.error ?? "", forType: .string)
103 |
104 | // Show 'copied' toast
105 | showToast.toggle()
106 | } label: {
107 | HStack(alignment: .center) {
108 | Text("copy-error")
109 |
110 | Image(systemName: "clipboard.fill")
111 | }
112 | .padding(.horizontal)
113 | .padding(.vertical, Spacing.l)
114 | .background(.ultraThickMaterial)
115 | .clipShape(RoundedRectangle(cornerRadius: 10))
116 | .shadow(radius: 3, x: 1, y: 2)
117 | }
118 | .buttonStyle(.plain)
119 |
120 | Button {
121 | window.close()
122 | } label: {
123 | HStack(alignment: .center) {
124 | Text("close-window")
125 |
126 | Image(systemName: "xmark")
127 | }
128 | .padding(.horizontal)
129 | .padding(.vertical, Spacing.l)
130 | .background(AppColor.Primary)
131 | .clipShape(RoundedRectangle(cornerRadius: 10))
132 | .shadow(radius: 3, x: 1, y: 2)
133 | }
134 | .buttonStyle(.plain)
135 | }
136 | }
137 | .toast(isPresenting: $showToast, duration: 1, tapToDismiss: true) {
138 | AlertToast(type: .regular, title: String(localized: "hint-copied"))
139 | }
140 | .padding(Spacing.xl)
141 | .frame(minWidth: 500, minHeight: 500)
142 | }
143 | }
144 |
145 | struct OutputWindowView_Previews: PreviewProvider {
146 | static var previews: some View {
147 | OutputWindowView(script: DefaultScript, window: NSWindow())
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/ScriptManager/Handler/ScriptHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScriptHandler.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 10.02.23.
6 | //
7 |
8 | import Foundation
9 | import Resolver
10 |
11 | @Observable
12 | class ScriptHandler: ScriptHandlerProtocol {
13 | @LazyInjected @ObservationIgnored private var storageHandler: StorageHandlerProtocol
14 | @LazyInjected @ObservationIgnored private var hintHandler: HintHandlerProtocol
15 |
16 | var finishedCounter = 0
17 |
18 | var scripts: [Script] = []
19 | var savedScripts: [Script] {
20 | storageHandler.scripts
21 | }
22 |
23 | var runningScript: [Script] = []
24 | var isRunning = false
25 | var runningProcesses: [ScriptProcess] = []
26 |
27 | var editScript = EmptyScript
28 | var editMode = false
29 | var selectedIcon = 0
30 | var input = ""
31 |
32 | private var process: Process?
33 | private var settings: Settings {
34 | storageHandler.settings
35 | }
36 |
37 | init() {
38 | scripts = storageHandler.scripts
39 | }
40 |
41 | func runScript(_ script: Script, test: Bool) async -> Result {
42 | guard let index = scripts.firstIndex(where: { $0.id == script.id }) else { return Result(state: .failed) }
43 |
44 | scripts[index].output = ""
45 | scripts[index].error = ""
46 |
47 | do {
48 | let process = Process()
49 | runningProcesses.append(ScriptProcess(scriptId: script.id, process: process))
50 |
51 | let inputPipe = Pipe()
52 | let outputPipe = Pipe()
53 | let errorPipe = Pipe()
54 |
55 | // Build valid shell command
56 | let unicode = "export LANG=\(settings.unicode);"
57 | let profilePath = settings.shell.profile != nil ? "source \(settings.shell.profile!);" : ""
58 | let validatedCommand = unicode + profilePath + script.command
59 |
60 | process.arguments = ["--login","-c", validatedCommand]
61 | process.executableURL = URL(fileURLWithPath: settings.shell.path)
62 |
63 | process.standardError = errorPipe
64 | process.standardInput = inputPipe
65 | process.standardOutput = outputPipe
66 |
67 | try process.run()
68 |
69 | // Input handling
70 | let inputHandle = inputPipe.fileHandleForWriting
71 | let userInput = script.input ?? ""
72 |
73 | inputHandle.write(userInput.data(using: .utf8)!)
74 | inputHandle.closeFile()
75 |
76 | // Output handling
77 | let outHandle = outputPipe.fileHandleForReading
78 | outHandle.readabilityHandler = { pipe in
79 | if let line = String(data: pipe.availableData, encoding: .utf8) {
80 | DispatchQueue.main.async {
81 | self.scripts[index].output! += line
82 | }
83 | } else {
84 | print("Error decoding data: \(pipe.availableData)")
85 | }
86 | }
87 |
88 | // Error handling
89 | let errorHandle = errorPipe.fileHandleForReading
90 | let errorData = errorHandle.readDataToEndOfFile()
91 | scripts[index].error = String(data: errorData, encoding: .utf8) ?? ""
92 |
93 | process.waitUntilExit()
94 |
95 | try outHandle.close()
96 |
97 | return handleScriptResult(
98 | result: process.terminationStatus,
99 | test: test,
100 | scriptName: script.name,
101 | output: scripts[index].output ?? "",
102 | error: scripts[index].error ?? ""
103 | )
104 | } catch {
105 | print(error)
106 | isRunning = false
107 | return Result(state: .failed)
108 | }
109 | }
110 |
111 | func interruptRunningProcess(scriptId: UUID) {
112 | runningProcesses.forEach { instance in
113 | if instance.scriptId == scriptId {
114 | instance.process.terminate()
115 | hintHandler.showHint(String(localized: "script-interrupted"), type: .warning)
116 | }
117 | }
118 | }
119 |
120 | func saveScripts() {
121 | storageHandler.scripts = scripts
122 | }
123 | }
124 |
125 | // MARK: - Handle process result
126 | extension ScriptHandler {
127 | private func handleScriptResult(result: Int32, test: Bool, scriptName: String, output: String, error: String) -> Result {
128 | if (result == 0) {
129 | // Script successfull
130 | if (settings.notifications && !test) {
131 | NotificationHandler.sendResultNotification(state: true, name: scriptName)
132 | }
133 |
134 | self.finishedCounter += 1
135 |
136 | isRunning = false
137 | return Result(output: output, error: error, state: .successfull)
138 | } else if (result == 15) {
139 | // Script interrupted
140 | isRunning = false
141 | return Result(output: output, error: error, state: .interrupted)
142 | } else {
143 | // Script failed
144 | if (settings.logs && !test) {
145 | writeLog(pathLogs: settings.pathLogs, output: output, error: error)
146 | }
147 |
148 | if (settings.notifications && !test) {
149 | NotificationHandler.sendResultNotification(state: false, name: scriptName)
150 | }
151 |
152 | isRunning = false
153 | return Result(output: output, error: error, state: .failed)
154 | }
155 | }
156 |
157 | private func writeLog(pathLogs: String, output: String, error: String) {
158 | let url = URL(string: "file://\(pathLogs)")
159 | guard let validUrl = url else { return }
160 |
161 | let fileName = validUrl.appendingPathComponent("log_\(Date().toFormattedDate()).txt")
162 |
163 | do {
164 | let log = output + "\n\n" + error
165 | try log.write(to: fileName, atomically: true, encoding: String.Encoding.utf8)
166 | } catch {
167 | print(error)
168 | }
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/ScriptManager/ViewModels/SettingsViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsHandler.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 06.02.23.
6 | //
7 |
8 | import Foundation
9 | import UserNotifications
10 | import Resolver
11 | import SwiftUI
12 |
13 | @Observable
14 | class SettingsViewModel {
15 | @LazyInjected @ObservationIgnored private var storageHandler: StorageHandlerProtocol
16 | @LazyInjected @ObservationIgnored private var alertHandler: AlertHandlerProtocol
17 | @LazyInjected @ObservationIgnored private var settingsHandler: SettingsHandlerProtocol
18 | @LazyInjected @ObservationIgnored private var tagHandler: TagHandlerProtocol
19 | @LazyInjected @ObservationIgnored private var hintHandler: HintHandlerProtocol
20 |
21 | @LazyInjected @ObservationIgnored var scriptHandler: ScriptHandlerProtocol
22 | @LazyInjected @ObservationIgnored var modalHandler: ModalHandlerProtocol
23 |
24 | var homeDir: String = ""
25 | var settings: Settings {
26 | settingsHandler.settings
27 | }
28 |
29 | var tempSettings: Settings = DefaultSettings
30 | var tempProfilePath: String = "" {
31 | didSet {
32 | tempSettings.shell.profile = tempProfilePath
33 | }
34 | }
35 |
36 | var tempShellType: ShellType = .zsh {
37 | didSet {
38 | tempSettings.shell.type = tempShellType
39 |
40 | // let shell: Shell = Shells.filter{ $0.type == tempSettings.shell.type }.first!
41 | // tempSettings.shell.path = shell.path
42 | // tempProfilePath = homeDir + (shell.profile ?? "")
43 | }
44 | }
45 |
46 | // Shortcut-Picker
47 | var selectedScript1: UUID = EmptyScript.id
48 | var selectedScript2: UUID = EmptyScript.id
49 | var selectedScript3: UUID = EmptyScript.id
50 | var selectedScript4: UUID = EmptyScript.id
51 | var selectedScript5: UUID = EmptyScript.id
52 |
53 | var selectedKeys1: String = ""
54 | var selectedKeys2: String = ""
55 | var selectedKeys3: String = ""
56 | var selectedKeys4: String = ""
57 | var selectedKeys5: String = ""
58 |
59 | init() {
60 | self.tempSettings = settings
61 | self.tempProfilePath = settings.shell.profile ?? ""
62 | self.tempShellType = settings.shell.type
63 |
64 | self.selectedScript1 = settings.shortcuts.count > 0 ? settings.shortcuts[0].scriptId : EmptyScript.id
65 | self.selectedScript2 = settings.shortcuts.count > 1 ? settings.shortcuts[1].scriptId : EmptyScript.id
66 | self.selectedScript3 = settings.shortcuts.count > 2 ? settings.shortcuts[2].scriptId : EmptyScript.id
67 | self.selectedScript4 = settings.shortcuts.count > 3 ? settings.shortcuts[3].scriptId : EmptyScript.id
68 | self.selectedScript5 = settings.shortcuts.count > 4 ? settings.shortcuts[4].scriptId : EmptyScript.id
69 | }
70 |
71 | func initSettings() {
72 | // Set initial home path
73 | homeDir = FileManager.default.homeDirectoryForCurrentUser.relativePath
74 | }
75 |
76 | func loadUserDir() -> String {
77 | // Load user home directory for shell-profile
78 | let homeDirURL = FileManager.default.homeDirectoryForCurrentUser.relativePath
79 | return "\(homeDirURL)/.zshrc"
80 | }
81 |
82 | func loadLogsDir() -> String {
83 | // Load documents folder for logs
84 | let dirPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
85 | return dirPath[0].relativePath
86 | }
87 |
88 | func reset() {
89 | // Delete all saved data
90 | storageHandler.resetData()
91 |
92 | scriptHandler.scripts = []
93 |
94 | tagHandler.selectedTag = nil
95 | tagHandler.tags = []
96 | }
97 |
98 | func openLoggingDirectory() {
99 | let folderChooserPoint = CGPoint(x: 0, y: 0)
100 | let folderChooserSize = CGSize(width: 500, height: 600)
101 | let folderChooserRectangle = CGRect(origin: folderChooserPoint, size: folderChooserSize)
102 | let folderPicker = NSOpenPanel(contentRect: folderChooserRectangle, styleMask: .utilityWindow, backing: .buffered, defer: true)
103 |
104 | folderPicker.canChooseDirectories = true
105 | folderPicker.canChooseFiles = false
106 | folderPicker.allowsMultipleSelection = false
107 | folderPicker.canDownloadUbiquitousContents = false
108 | folderPicker.canResolveUbiquitousConflicts = true
109 |
110 | folderPicker.begin { response in
111 | if response == .OK {
112 | let pickedFolder = folderPicker.url
113 | if let pickedFolder {
114 | self.tempSettings.pathLogs = pickedFolder.path()
115 | }
116 | }
117 | }
118 | }
119 |
120 | func activateNotifications() {
121 | if (settingsHandler.settings.notifications) {
122 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert], completionHandler: { success, error in
123 | if success {
124 | print("Notifications allowed")
125 | } else if let error = error {
126 | print(error.localizedDescription)
127 | self.settingsHandler.settings.notifications = false
128 | }
129 | })
130 | }
131 | }
132 |
133 | @MainActor
134 | func saveSettings() {
135 | tempSettings.shortcuts = [
136 | Shortcut(shortcutIndex: 0, scriptId: selectedScript1, keys: selectedKeys1),
137 | Shortcut(shortcutIndex: 1, scriptId: selectedScript2, keys: selectedKeys2),
138 | Shortcut(shortcutIndex: 2, scriptId: selectedScript3, keys: selectedKeys3),
139 | Shortcut(shortcutIndex: 3, scriptId: selectedScript4, keys: selectedKeys4),
140 | Shortcut(shortcutIndex: 4, scriptId: selectedScript5, keys: selectedKeys5)
141 | ]
142 |
143 | tempSettings.shell.type = tempShellType
144 | tempSettings.shell.profile = tempProfilePath
145 | settingsHandler.settings = tempSettings
146 | settingsHandler.saveSettings()
147 |
148 | // Hide settings-modal
149 | modalHandler.hideModal()
150 | hintHandler.showHint(String(localized: "saved-settings"), type: .success)
151 | }
152 |
153 | @MainActor
154 | func showCloseAlert() {
155 | alertHandler.showAlert(
156 | title: String(localized: "close-app-title"),
157 | message: String(localized: "close-app-msg"),
158 | btnTitle: String(localized: "close-app-btn"),
159 | cancelVisible: true,
160 | action: {
161 | NSApp.terminate(self)
162 | }
163 | )
164 | }
165 |
166 | @MainActor
167 | func showResetAlert() {
168 | alertHandler.showAlert(
169 | title: String(localized: "settings-delete-title"),
170 | message: String(localized: "settings-delete-msg"),
171 | btnTitle: String(localized: "delete"),
172 | cancelVisible: true,
173 | action: {
174 | self.reset()
175 | }
176 | )
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/ScriptManager/Views/Scripts/ScriptsListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScriptsListView.swift
3 | // ScriptManager
4 | //
5 | // Created by Filler, Daniel on 03.02.23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ScriptsListView: View {
11 | @State private var vm = ScriptViewModel()
12 | @State private var vmTags = TagViewModel()
13 | @State private var showAddScriptModal = false
14 | @State private var showSearch = false
15 |
16 | var body: some View {
17 | VStack(alignment: .leading, spacing: Spacing.l) {
18 | HStack(alignment: .center) {
19 | if !showSearch {
20 | Text("saved \(String(vm.scripts.count))")
21 | .fontWeight(.bold)
22 | .font(.headline)
23 | .padding(.trailing, Spacing.m)
24 | } else {
25 | if vm.scripts.count == 1 {
26 | Text("\(String(vm.scripts.count)) result")
27 | .fontWeight(.bold)
28 | .font(.headline)
29 | .padding(.trailing, Spacing.m)
30 | } else {
31 | Text("\(String(vm.scripts.count)) results")
32 | .fontWeight(.bold)
33 | .font(.headline)
34 | .padding(.trailing, Spacing.m)
35 | }
36 | }
37 |
38 | Spacer()
39 |
40 | SearchbarView(vm: $vm, show: $showSearch)
41 | }
42 | .padding(.bottom, Spacing.l)
43 |
44 | TagsListView()
45 |
46 | if (vm.scripts.isEmpty) {
47 | VStack(alignment: .center) {
48 | Spacer()
49 |
50 | if vm.tagHandler.selectedTag != nil {
51 | // Tag is active
52 | Image(systemName: "doc.text.magnifyingglass")
53 | .resizable()
54 | .scaledToFit()
55 | .frame(height: 60)
56 | .symbolEffect(.bounce, options: .nonRepeating)
57 |
58 | Text("empty-scripts-filter")
59 | .font(.caption2)
60 | .foregroundColor(AppColor.Creme)
61 | .padding()
62 | .frame(maxWidth: .infinity)
63 | .multilineTextAlignment(.center)
64 |
65 | Button {
66 | vmTags.setActiveTag(nil)
67 | } label: {
68 | HStack {
69 | Spacer()
70 | Text("remove-filter")
71 | Spacer()
72 | Image(systemName: "tag.slash")
73 | }
74 | .frame(width: 150)
75 | .padding()
76 | .background(.ultraThinMaterial)
77 | .cornerRadius(15)
78 | .shadow(radius: 3, x: 1, y: 2)
79 | }
80 | .buttonStyle(.plain)
81 |
82 | } else if !vm.searchString.isEmpty {
83 | // Search is active
84 | Image(systemName: "doc.text.magnifyingglass")
85 | .resizable()
86 | .scaledToFit()
87 | .frame(height: 60)
88 | .symbolEffect(.bounce, options: .nonRepeating)
89 |
90 | Text("empty-scripts-search")
91 | .font(.caption2)
92 | .foregroundColor(AppColor.Creme)
93 | .padding()
94 | .frame(maxWidth: .infinity)
95 | .multilineTextAlignment(.center)
96 |
97 | Button {
98 | vm.searchString.removeAll()
99 | } label: {
100 | HStack {
101 | Spacer()
102 | Text("remove-search")
103 | Spacer()
104 | Image(systemName: "minus.magnifyingglass")
105 | }
106 | .frame(width: 150)
107 | .padding()
108 | .background(.ultraThinMaterial)
109 | .cornerRadius(15)
110 | .shadow(radius: 3, x: 1, y: 2)
111 | }
112 | .buttonStyle(.plain)
113 | } else {
114 | // No scripts saved
115 | Image(systemName: "doc.badge.plus")
116 | .resizable()
117 | .scaledToFit()
118 | .frame(height: 60)
119 | .symbolEffect(.bounce, options: .nonRepeating)
120 |
121 | Text("empty-scripts")
122 | .font(.caption2)
123 | .foregroundColor(AppColor.Creme)
124 | .padding()
125 | .frame(maxWidth: .infinity)
126 | .multilineTextAlignment(.center)
127 |
128 | Button {
129 | vm.modalHandler.showModal(.ADD_SCRIPT)
130 | } label: {
131 | HStack {
132 | Spacer()
133 | Text("add-new-script")
134 | Spacer()
135 | Image(systemName: "doc")
136 | }
137 | .frame(width: 200)
138 | .padding()
139 | .background(.ultraThinMaterial)
140 | .cornerRadius(15)
141 | .shadow(radius: 3, x: 1, y: 2)
142 | }
143 | .buttonStyle(.plain)
144 | }
145 |
146 | Spacer()
147 | }
148 | } else {
149 | ScrollView {
150 | ForEach(vm.scripts) { script in
151 | ScriptRowView(showAddScriptModal: $showAddScriptModal, script: script)
152 | .padding(.horizontal, Spacing.l)
153 | .padding(.bottom, Spacing.m)
154 | }
155 |
156 | Spacer()
157 | }
158 | }
159 | }
160 | .padding(.horizontal, Spacing.xl + 4)
161 | .padding(.top, Spacing.m)
162 | }
163 | }
164 |
165 | struct ScriptsListView_Previews: PreviewProvider {
166 | static var previews: some View {
167 | ScriptsListView()
168 | .background(.gray)
169 | }
170 | }
171 |
--------------------------------------------------------------------------------